@persistmemory/cli 0.6.0 → 0.7.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.
- package/dist/bin.js +868 -88
- package/dist/bin.js.map +4 -4
- package/dist/index.js +868 -88
- package/dist/index.js.map +4 -4
- package/package.json +2 -2
package/dist/bin.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
//
|
|
3
|
+
// ../sdk-js/dist/index.js
|
|
4
4
|
function encodeQuery(params) {
|
|
5
5
|
if (!params) return "";
|
|
6
6
|
const search = new URLSearchParams();
|
|
@@ -234,6 +234,27 @@ var HttpClient = class {
|
|
|
234
234
|
...options ? { options } : {}
|
|
235
235
|
});
|
|
236
236
|
}
|
|
237
|
+
/** POST with a file as the body. The type describes the bytes, not JSON. */
|
|
238
|
+
async postBytes(path, bytes, contentType, query, options) {
|
|
239
|
+
return this.#request({
|
|
240
|
+
method: "POST",
|
|
241
|
+
path,
|
|
242
|
+
rawBody: bytes,
|
|
243
|
+
contentType,
|
|
244
|
+
...query ? { query } : {},
|
|
245
|
+
...options ? { options } : {}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
/** GET that returns bytes rather than JSON, for downloading a file. */
|
|
249
|
+
async getBytes(path, query, options) {
|
|
250
|
+
return this.#request({
|
|
251
|
+
method: "GET",
|
|
252
|
+
path,
|
|
253
|
+
rawResponse: true,
|
|
254
|
+
...query ? { query } : {},
|
|
255
|
+
...options ? { options } : {}
|
|
256
|
+
});
|
|
257
|
+
}
|
|
237
258
|
async patch(path, body, options) {
|
|
238
259
|
return this.#request({
|
|
239
260
|
method: "PATCH",
|
|
@@ -286,11 +307,20 @@ var HttpClient = class {
|
|
|
286
307
|
this.#fetch(url, {
|
|
287
308
|
method: request.method,
|
|
288
309
|
headers: this.#headers(request),
|
|
289
|
-
...request.body !== void 0 ? { body: JSON.stringify(request.body) } : {},
|
|
310
|
+
...request.rawBody !== void 0 ? { body: request.rawBody } : request.body !== void 0 ? { body: JSON.stringify(request.body) } : {},
|
|
290
311
|
signal: deadline.signal
|
|
291
312
|
}),
|
|
292
313
|
deadline.signal
|
|
293
314
|
);
|
|
315
|
+
if (request.rawResponse && response.ok) {
|
|
316
|
+
const disposition = response.headers.get("content-disposition") ?? "";
|
|
317
|
+
const named = /filename="([^"]+)"/.exec(disposition)?.[1];
|
|
318
|
+
return {
|
|
319
|
+
bytes: new Uint8Array(await response.arrayBuffer()),
|
|
320
|
+
contentType: response.headers.get("content-type") ?? "application/octet-stream",
|
|
321
|
+
...named ? { filename: named } : {}
|
|
322
|
+
};
|
|
323
|
+
}
|
|
294
324
|
const payload = await readBody(response);
|
|
295
325
|
if (!response.ok) throw errorFromResponse(response.status, payload, response.headers);
|
|
296
326
|
return payload;
|
|
@@ -311,9 +341,11 @@ var HttpClient = class {
|
|
|
311
341
|
return {
|
|
312
342
|
// The only place the key is ever read.
|
|
313
343
|
authorization: `Bearer ${this.#apiKey}`,
|
|
314
|
-
|
|
344
|
+
// A download route answers with the file's own type, so `*/*` rather
|
|
345
|
+
// than a promise to accept only JSON that the server would have to break.
|
|
346
|
+
accept: request.rawResponse ? "*/*" : "application/json",
|
|
315
347
|
"user-agent": this.#userAgent,
|
|
316
|
-
...request.body !== void 0 ? { "content-type": "application/json" } : {},
|
|
348
|
+
...request.rawBody !== void 0 ? { "content-type": request.contentType ?? "application/octet-stream" } : request.body !== void 0 ? { "content-type": "application/json" } : {},
|
|
317
349
|
...request.options?.idempotencyKey ? { "idempotency-key": request.options.idempotencyKey } : {}
|
|
318
350
|
};
|
|
319
351
|
}
|
|
@@ -617,17 +649,22 @@ var Spaces = class {
|
|
|
617
649
|
/**
|
|
618
650
|
* The memories filed in a Space.
|
|
619
651
|
*
|
|
620
|
-
*
|
|
621
|
-
*
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
*
|
|
652
|
+
* The cursor is PASSED. This fetch used to ignore the paginator's cursor
|
|
653
|
+
* on the stale belief that the endpoint had none — the server has minted
|
|
654
|
+
* `pagination.nextCursor` since it started paging, and its own comment
|
|
655
|
+
* says "both SDKs iterate by reading pagination". Ignoring it meant every
|
|
656
|
+
* page request was identical: the loop guard saw a non-advancing fetch and
|
|
657
|
+
* stopped silently, so `all()` returned the first page twice and dropped
|
|
658
|
+
* everything after it — duplicated AND truncated data, with no error.
|
|
625
659
|
*/
|
|
626
660
|
memories(id, params = {}, options) {
|
|
627
661
|
return new Paginated(
|
|
628
|
-
() => this.#http.get(
|
|
662
|
+
(cursor) => this.#http.get(
|
|
629
663
|
`/api/v1/spaces/${encodeURIComponent(id)}/memories`,
|
|
630
|
-
{
|
|
664
|
+
{
|
|
665
|
+
...params.limit !== void 0 ? { limit: params.limit } : {},
|
|
666
|
+
...cursor !== void 0 ? { cursor } : {}
|
|
667
|
+
},
|
|
631
668
|
options
|
|
632
669
|
)
|
|
633
670
|
);
|
|
@@ -653,6 +690,94 @@ var Spaces = class {
|
|
|
653
690
|
options
|
|
654
691
|
);
|
|
655
692
|
}
|
|
693
|
+
/* ----------------------- who else can see it ----------------------- */
|
|
694
|
+
/**
|
|
695
|
+
* Who can see this Space, including invitations nobody has accepted.
|
|
696
|
+
*
|
|
697
|
+
* A DIFFERENT EDGE from `memories()` next door, and the difference is worth
|
|
698
|
+
* holding on to: that one maps a MEMORY to a Space, this one maps a PERSON
|
|
699
|
+
* to a Space. The server keeps them in two tables with two names for exactly
|
|
700
|
+
* that reason.
|
|
701
|
+
*
|
|
702
|
+
* Read `acceptedAt` before you render a row. An invitation grants nothing
|
|
703
|
+
* until it is accepted, so a list that draws invited and accepted people the
|
|
704
|
+
* same way tells its user somebody is reading their memories when nobody is.
|
|
705
|
+
*
|
|
706
|
+
* Paginated like every other list here. A Space has a handful of
|
|
707
|
+
* collaborators rather than thousands, so this will usually be one page -
|
|
708
|
+
* which costs a caller nothing and means the shape does not change if a
|
|
709
|
+
* Space ever has an organisation on it.
|
|
710
|
+
*/
|
|
711
|
+
collaborators(id, params = {}, options) {
|
|
712
|
+
return new Paginated(
|
|
713
|
+
(cursor) => this.#http.get(
|
|
714
|
+
`/api/v1/sharing/spaces/${encodeURIComponent(id)}/collaborators`,
|
|
715
|
+
{
|
|
716
|
+
...params.limit !== void 0 ? { limit: params.limit } : {},
|
|
717
|
+
...cursor !== void 0 ? { cursor } : {}
|
|
718
|
+
},
|
|
719
|
+
options
|
|
720
|
+
)
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Offers somebody sight of a Space. Answers with the invitation.
|
|
725
|
+
*
|
|
726
|
+
* AN OFFER, NOT A GRANT, and the returned `acceptedAt` will be absent to
|
|
727
|
+
* prove it. The recipient has to accept before they can see anything, which
|
|
728
|
+
* is the property that keeps "nothing enters your memory without you" true
|
|
729
|
+
* even when somebody else starts the sharing. Do not tell your user their
|
|
730
|
+
* Space "has been shared" on the strength of a 2xx here.
|
|
731
|
+
*
|
|
732
|
+
* WHAT THEY GET IS THE WHOLE SPACE: every memory already filed in it and
|
|
733
|
+
* every memory that lands in it afterwards. There is no narrower grant, and
|
|
734
|
+
* `role` does not make one - it decides what they may do BESIDES read.
|
|
735
|
+
*
|
|
736
|
+
* Worth an idempotency key when a person is behind it. A double-clicked
|
|
737
|
+
* "share" is two invitations to the same address, and the second one is a
|
|
738
|
+
* second email arriving at somebody who has already been asked.
|
|
739
|
+
*/
|
|
740
|
+
async share(id, params, options) {
|
|
741
|
+
return this.#http.post(
|
|
742
|
+
`/api/v1/sharing/spaces/${encodeURIComponent(id)}/collaborators`,
|
|
743
|
+
params,
|
|
744
|
+
options
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Ends somebody's access, or withdraws an invitation they never accepted.
|
|
749
|
+
*
|
|
750
|
+
* Nothing was ever copied into their account - a collaborator SEES the
|
|
751
|
+
* owner's memories rather than holding a duplicate - so this is one write
|
|
752
|
+
* and not a cascade, and there is no orphaned copy left behind.
|
|
753
|
+
*
|
|
754
|
+
* A body on a DELETE, matching `removeMemories` above. The alternative is an
|
|
755
|
+
* address in a path segment, where every `.`, `+` and `@` is a chance for a
|
|
756
|
+
* proxy or a router to normalise somebody else's email into the one that
|
|
757
|
+
* gets revoked.
|
|
758
|
+
*/
|
|
759
|
+
async unshare(id, email, options) {
|
|
760
|
+
return this.#http.delete(
|
|
761
|
+
`/api/v1/sharing/spaces/${encodeURIComponent(id)}/collaborators`,
|
|
762
|
+
{ email },
|
|
763
|
+
options
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Changes what an existing collaborator may do. Never invites anybody.
|
|
768
|
+
*
|
|
769
|
+
* The quiet one. Moving somebody from `viewer` to `owner` sends no
|
|
770
|
+
* invitation and needs no acceptance, and afterwards they can share the
|
|
771
|
+
* Space onward and revoke the person who promoted them. Show your user what
|
|
772
|
+
* `owner` means before you send this, not after.
|
|
773
|
+
*/
|
|
774
|
+
async setRole(id, params, options) {
|
|
775
|
+
return this.#http.patch(
|
|
776
|
+
`/api/v1/sharing/spaces/${encodeURIComponent(id)}/collaborators`,
|
|
777
|
+
params,
|
|
778
|
+
options
|
|
779
|
+
);
|
|
780
|
+
}
|
|
656
781
|
};
|
|
657
782
|
var Sources = class {
|
|
658
783
|
#http;
|
|
@@ -890,6 +1015,119 @@ var Conversations = class {
|
|
|
890
1015
|
);
|
|
891
1016
|
}
|
|
892
1017
|
};
|
|
1018
|
+
var Google = class {
|
|
1019
|
+
#http;
|
|
1020
|
+
constructor(http) {
|
|
1021
|
+
this.#http = http;
|
|
1022
|
+
}
|
|
1023
|
+
/** Files by name, newest first. Omit the query for recently changed ones. */
|
|
1024
|
+
async searchDrive(params = {}, options) {
|
|
1025
|
+
return this.#http.get(
|
|
1026
|
+
"/api/v1/google/drive/files",
|
|
1027
|
+
{
|
|
1028
|
+
...params.query !== void 0 ? { query: params.query } : {},
|
|
1029
|
+
...params.limit !== void 0 ? { limit: params.limit } : {}
|
|
1030
|
+
},
|
|
1031
|
+
options
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
async getDriveFile(fileId, options) {
|
|
1035
|
+
return this.#http.get(
|
|
1036
|
+
`/api/v1/google/drive/files/${encodeURIComponent(fileId)}`,
|
|
1037
|
+
void 0,
|
|
1038
|
+
options
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* The bytes of a Drive file.
|
|
1043
|
+
*
|
|
1044
|
+
* A Google Doc, Sheet or Slide holds no bytes of its own and is exported on
|
|
1045
|
+
* the way - a document as PDF, a spreadsheet as CSV - so `filename` comes
|
|
1046
|
+
* back describing what it BECAME. Writing it under the id instead produces a
|
|
1047
|
+
* file nothing will open.
|
|
1048
|
+
*/
|
|
1049
|
+
async downloadDriveFile(fileId, options) {
|
|
1050
|
+
return this.#http.getBytes(
|
|
1051
|
+
`/api/v1/google/drive/files/${encodeURIComponent(fileId)}/content`,
|
|
1052
|
+
void 0,
|
|
1053
|
+
options
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Writes a file into the user's Drive.
|
|
1058
|
+
*
|
|
1059
|
+
* Needs one of the Drive write permissions on their connection. A read-only
|
|
1060
|
+
* grant is refused by Google, and the error names the missing permission
|
|
1061
|
+
* rather than reporting a failed upload - one is fixed with a checkbox and
|
|
1062
|
+
* the other sends somebody looking for a bug.
|
|
1063
|
+
*/
|
|
1064
|
+
async saveToDrive(params, options) {
|
|
1065
|
+
return this.#http.postBytes(
|
|
1066
|
+
"/api/v1/google/drive/files",
|
|
1067
|
+
params.bytes,
|
|
1068
|
+
params.contentType ?? "application/octet-stream",
|
|
1069
|
+
{
|
|
1070
|
+
name: params.name,
|
|
1071
|
+
...params.folderId !== void 0 ? { folderId: params.folderId } : {}
|
|
1072
|
+
},
|
|
1073
|
+
options
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Recent messages - senders, subjects and a one-line preview, never bodies.
|
|
1078
|
+
*
|
|
1079
|
+
* `query` is Gmail's own syntax passed through as written: `from:priya`,
|
|
1080
|
+
* `has:attachment`, `newer_than:7d`. It selects within the connected mailbox
|
|
1081
|
+
* and cannot reach another one.
|
|
1082
|
+
*/
|
|
1083
|
+
async searchMail(params = {}, options) {
|
|
1084
|
+
return this.#http.get(
|
|
1085
|
+
"/api/v1/google/mail",
|
|
1086
|
+
{
|
|
1087
|
+
...params.query !== void 0 ? { query: params.query } : {},
|
|
1088
|
+
...params.limit !== void 0 ? { limit: params.limit } : {}
|
|
1089
|
+
},
|
|
1090
|
+
options
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
/** One message, with its body and the names of what is attached. */
|
|
1094
|
+
async readMail(messageId, options) {
|
|
1095
|
+
return this.#http.get(
|
|
1096
|
+
`/api/v1/google/mail/${encodeURIComponent(messageId)}`,
|
|
1097
|
+
void 0,
|
|
1098
|
+
options
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* The bytes of one attachment.
|
|
1103
|
+
*
|
|
1104
|
+
* Separate from `readMail` so listing a mailbox never drags attachments
|
|
1105
|
+
* across the network: a message with a 40 MB deck should not cost 40 MB to
|
|
1106
|
+
* summarise.
|
|
1107
|
+
*/
|
|
1108
|
+
async downloadAttachment(messageId, attachmentId, options) {
|
|
1109
|
+
return this.#http.getBytes(
|
|
1110
|
+
`/api/v1/google/mail/${encodeURIComponent(messageId)}/attachments/${encodeURIComponent(attachmentId)}`,
|
|
1111
|
+
void 0,
|
|
1112
|
+
options
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
/** Sends as the connected account. Needs the send permission. */
|
|
1116
|
+
async sendMail(params, options) {
|
|
1117
|
+
return this.#http.post("/api/v1/google/mail/send", params, options);
|
|
1118
|
+
}
|
|
1119
|
+
/** People in the user's contacts. Omit the query to list them. */
|
|
1120
|
+
async contacts(params = {}, options) {
|
|
1121
|
+
return this.#http.get(
|
|
1122
|
+
"/api/v1/google/contacts",
|
|
1123
|
+
{
|
|
1124
|
+
...params.query !== void 0 ? { query: params.query } : {},
|
|
1125
|
+
...params.limit !== void 0 ? { limit: params.limit } : {}
|
|
1126
|
+
},
|
|
1127
|
+
options
|
|
1128
|
+
);
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
893
1131
|
var Integrations = class {
|
|
894
1132
|
#http;
|
|
895
1133
|
constructor(http) {
|
|
@@ -1020,6 +1258,8 @@ var PersistMemory = class {
|
|
|
1020
1258
|
conflicts;
|
|
1021
1259
|
conversations;
|
|
1022
1260
|
integrations;
|
|
1261
|
+
/** Drive, mail and contacts on the user's connected Google account. */
|
|
1262
|
+
google;
|
|
1023
1263
|
health;
|
|
1024
1264
|
agent;
|
|
1025
1265
|
#http;
|
|
@@ -1036,6 +1276,7 @@ var PersistMemory = class {
|
|
|
1036
1276
|
this.conflicts = new Conflicts(this.#http);
|
|
1037
1277
|
this.conversations = new Conversations(this.#http);
|
|
1038
1278
|
this.integrations = new Integrations(this.#http);
|
|
1279
|
+
this.google = new Google(this.#http);
|
|
1039
1280
|
this.health = new Health(this.#http);
|
|
1040
1281
|
this.agent = new Agent(this.#http);
|
|
1041
1282
|
}
|
|
@@ -1293,6 +1534,23 @@ function maskToken(token) {
|
|
|
1293
1534
|
return `${token.slice(0, 4)}\u2026${token.slice(-4)}`;
|
|
1294
1535
|
}
|
|
1295
1536
|
|
|
1537
|
+
// src/display-safe.ts
|
|
1538
|
+
var NEUTRALISED = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u061c\u200b\u200e\u200f\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff\ufff9-\ufffb]|[\u{e0000}-\u{e007f}]/gu;
|
|
1539
|
+
var LINE_BREAKS = /\r\n|[\r\u2028\u2029]/g;
|
|
1540
|
+
function displaySafe(text) {
|
|
1541
|
+
return text.replace(LINE_BREAKS, "\n").replace(NEUTRALISED, "");
|
|
1542
|
+
}
|
|
1543
|
+
function jsonSafe(value) {
|
|
1544
|
+
return JSON.stringify(value, void 0, 2).replace(
|
|
1545
|
+
NEUTRALISED,
|
|
1546
|
+
(match) => (
|
|
1547
|
+
// Per UTF-16 unit, so a tags-block codepoint becomes its surrogate pair
|
|
1548
|
+
// rather than one escape JSON cannot represent.
|
|
1549
|
+
match.split("").map((unit) => "\\u" + unit.charCodeAt(0).toString(16).padStart(4, "0")).join("")
|
|
1550
|
+
)
|
|
1551
|
+
);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1296
1554
|
// src/output.ts
|
|
1297
1555
|
var OUTPUT_FORMATS = ["table", "json", "yaml", "csv", "tsv"];
|
|
1298
1556
|
function isOutputFormat(value) {
|
|
@@ -1301,7 +1559,7 @@ function isOutputFormat(value) {
|
|
|
1301
1559
|
function render(rows, columns, options) {
|
|
1302
1560
|
switch (options.format) {
|
|
1303
1561
|
case "json":
|
|
1304
|
-
return
|
|
1562
|
+
return jsonSafe(rows);
|
|
1305
1563
|
case "yaml":
|
|
1306
1564
|
return toYaml(rows);
|
|
1307
1565
|
case "csv":
|
|
@@ -1313,13 +1571,13 @@ function render(rows, columns, options) {
|
|
|
1313
1571
|
}
|
|
1314
1572
|
}
|
|
1315
1573
|
function renderOne(row, fields, options) {
|
|
1316
|
-
if (options.format === "json") return
|
|
1574
|
+
if (options.format === "json") return jsonSafe(row);
|
|
1317
1575
|
if (options.format === "yaml") return toYaml(row);
|
|
1318
1576
|
if (options.format === "csv" || options.format === "tsv") {
|
|
1319
1577
|
return delimited([row], fields, options.format === "csv" ? "," : " ");
|
|
1320
1578
|
}
|
|
1321
1579
|
const width = Math.max(...fields.map((field) => field.header.length));
|
|
1322
|
-
return fields.map((field) => `${field.header.padEnd(width)} ${field.value(row)}`).join("\n");
|
|
1580
|
+
return fields.map((field) => `${field.header.padEnd(width)} ${displaySafe(field.value(row))}`).join("\n");
|
|
1323
1581
|
}
|
|
1324
1582
|
function table(rows, columns, width = process.stdout.columns || 120) {
|
|
1325
1583
|
if (rows.length === 0) return "";
|
|
@@ -1379,7 +1637,8 @@ function isBlock(value) {
|
|
|
1379
1637
|
if (Array.isArray(value)) return value.length > 0;
|
|
1380
1638
|
return typeof value === "object" && value !== null && Object.keys(value).length > 0;
|
|
1381
1639
|
}
|
|
1382
|
-
function yamlString(
|
|
1640
|
+
function yamlString(text) {
|
|
1641
|
+
const value = displaySafe(text);
|
|
1383
1642
|
if (value === "") return '""';
|
|
1384
1643
|
if (value.includes("\n")) {
|
|
1385
1644
|
return `|-
|
|
@@ -1391,7 +1650,7 @@ ${value.split("\n").map((line) => ` ${line}`).join("\n")}`;
|
|
|
1391
1650
|
return ambiguous ? `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : value;
|
|
1392
1651
|
}
|
|
1393
1652
|
function oneLine(value) {
|
|
1394
|
-
return value.replace(/\s*\n\s*/g, " ").trim();
|
|
1653
|
+
return displaySafe(value).replace(/\s*\n\s*/g, " ").trim();
|
|
1395
1654
|
}
|
|
1396
1655
|
function clip(value, width) {
|
|
1397
1656
|
if (value.length <= width) return value;
|
|
@@ -1404,7 +1663,7 @@ function shortDate(iso) {
|
|
|
1404
1663
|
}
|
|
1405
1664
|
|
|
1406
1665
|
// src/help.ts
|
|
1407
|
-
var VERSION = true ? "0.
|
|
1666
|
+
var VERSION = true ? "0.7.0" : versionFromManifest();
|
|
1408
1667
|
var PACKAGE = "@persistmemory/cli";
|
|
1409
1668
|
var HELP = `
|
|
1410
1669
|
pm \u2014 PersistMemory from your terminal
|
|
@@ -1442,6 +1701,15 @@ var HELP = `
|
|
|
1442
1701
|
to what is in it
|
|
1443
1702
|
spaces merge "A" "B" --name "C" a new Space holding both, originals kept
|
|
1444
1703
|
|
|
1704
|
+
spaces sharing "Acme" who can see it, and who has accepted
|
|
1705
|
+
spaces share "Acme" <email> --role viewer|editor
|
|
1706
|
+
offer somebody sight of EVERYTHING in it,
|
|
1707
|
+
now and later. You must say the role.
|
|
1708
|
+
They see nothing until they accept.
|
|
1709
|
+
spaces unshare "Acme" <email> end their access
|
|
1710
|
+
spaces role "Acme" <email> editor
|
|
1711
|
+
change what an existing collaborator may do
|
|
1712
|
+
|
|
1445
1713
|
remember <text> capture text
|
|
1446
1714
|
remember - capture whatever is piped in
|
|
1447
1715
|
remember --file <path> capture a file's contents
|
|
@@ -2183,9 +2451,26 @@ ${head.join("\n")}`;
|
|
|
2183
2451
|
// src/commands/run-command.ts
|
|
2184
2452
|
import { spawn as spawn2 } from "node:child_process";
|
|
2185
2453
|
import { existsSync as existsSync4, readFileSync as readFileSync4, statSync as statSync2 } from "node:fs";
|
|
2186
|
-
import { isAbsolute as isAbsolute2, join as join4, resolve as resolvePath } from "node:path";
|
|
2454
|
+
import { isAbsolute as isAbsolute2, join as join4, relative as relative2, resolve as resolvePath } from "node:path";
|
|
2187
2455
|
var CLASSES = new Map([
|
|
2188
|
-
|
|
2456
|
+
/*
|
|
2457
|
+
Reads. Report on the machine and change nothing.
|
|
2458
|
+
|
|
2459
|
+
Several of these are reads only until a particular flag is passed —
|
|
2460
|
+
`find -exec` runs any program, `sort -o` writes a file, `dmesg -C` empties
|
|
2461
|
+
the kernel buffer — and the name alone cannot see that. `FLAG_CHANGES_CLASS`
|
|
2462
|
+
below is where those come back out again; a name in this list is the
|
|
2463
|
+
starting point, not the verdict.
|
|
2464
|
+
|
|
2465
|
+
`journalctl`, `dmesg` and `zcat` are here because of what people actually
|
|
2466
|
+
ask for. "Check the logs on my server" is `journalctl -n 200 -u nginx`,
|
|
2467
|
+
`dmesg -T`, or `zcat` on a rotated `.gz`, and leaving all three in
|
|
2468
|
+
`unknown` bought nothing: every command already waits for a person, so the
|
|
2469
|
+
only thing an honest `read` label changes is that it stops being a lie.
|
|
2470
|
+
`zcat` earns it by only ever writing to stdout — `gunzip` and `gzip -d`
|
|
2471
|
+
delete their input, which is why neither is here — and the decompression
|
|
2472
|
+
bomb it can be handed is bounded by the output cap rather than by trust.
|
|
2473
|
+
*/
|
|
2189
2474
|
...[
|
|
2190
2475
|
"ls",
|
|
2191
2476
|
"cat",
|
|
@@ -2213,7 +2498,10 @@ var CLASSES = new Map([
|
|
|
2213
2498
|
"dirname",
|
|
2214
2499
|
"realpath",
|
|
2215
2500
|
"ps",
|
|
2216
|
-
"env"
|
|
2501
|
+
"env",
|
|
2502
|
+
"journalctl",
|
|
2503
|
+
"dmesg",
|
|
2504
|
+
"zcat"
|
|
2217
2505
|
].map((name) => [name, "read"]),
|
|
2218
2506
|
// Writes. Recoverable or not, they change the machine.
|
|
2219
2507
|
...[
|
|
@@ -2260,16 +2548,159 @@ var SUBCOMMAND_READS = /* @__PURE__ */ new Map([
|
|
|
2260
2548
|
"blame",
|
|
2261
2549
|
"shortlog",
|
|
2262
2550
|
"ls-files",
|
|
2263
|
-
"rev-parse"
|
|
2264
|
-
|
|
2551
|
+
"rev-parse"
|
|
2552
|
+
])
|
|
2553
|
+
],
|
|
2554
|
+
["npm", /* @__PURE__ */ new Set(["ls", "list", "view", "outdated", "why"])],
|
|
2555
|
+
["yarn", /* @__PURE__ */ new Set(["list", "why", "info"])],
|
|
2556
|
+
/*
|
|
2557
|
+
`docker logs` STAYS a read, having been looked at again.
|
|
2558
|
+
|
|
2559
|
+
The case against it is real: every docker subcommand is a request to a
|
|
2560
|
+
daemon running as root, so "it only reads" is a statement about the
|
|
2561
|
+
subcommand and not about the socket it is sent down. The case for keeping
|
|
2562
|
+
it is that `logs` cannot start, stop or change anything — it prints what a
|
|
2563
|
+
container already wrote to its own stdout — and it is one of the three
|
|
2564
|
+
things anybody means by "check the logs on my server".
|
|
2565
|
+
|
|
2566
|
+
What actually needed fixing was not the label but the flags in front of
|
|
2567
|
+
it: see `REMOTE_FLAGS`. The residue this leaves is `docker --config=<dir>
|
|
2568
|
+
logs x`, which could load a CLI plugin from a directory the argv chose;
|
|
2569
|
+
`logs` is built in rather than a plugin, and a plugin has to be planted on
|
|
2570
|
+
the disk first, so it is left standing rather than papered over here.
|
|
2571
|
+
*/
|
|
2572
|
+
["docker", /* @__PURE__ */ new Set(["ps", "images", "logs", "inspect"])],
|
|
2573
|
+
/*
|
|
2574
|
+
`systemctl`, which was in no table at all and so was `unknown` whole.
|
|
2575
|
+
|
|
2576
|
+
It is the same shape as `git` and the reason this map exists: `systemctl
|
|
2577
|
+
status nginx` asks the manager a question over its bus, and `systemctl
|
|
2578
|
+
stop nginx` takes somebody's website down. One name, two powers, and the
|
|
2579
|
+
first non-flag word is the whole difference. Everything not listed —
|
|
2580
|
+
start, stop, restart, enable, mask, daemon-reload — lands in `write`,
|
|
2581
|
+
which is where it belongs and where it already effectively was.
|
|
2582
|
+
|
|
2583
|
+
`-H user@host` is not here because it is not a subcommand: it makes
|
|
2584
|
+
systemctl talk to ANOTHER machine over ssh, which is `REMOTE_FLAGS` below
|
|
2585
|
+
and a refusal, not a class.
|
|
2586
|
+
*/
|
|
2587
|
+
[
|
|
2588
|
+
"systemctl",
|
|
2589
|
+
/* @__PURE__ */ new Set([
|
|
2590
|
+
"status",
|
|
2591
|
+
"show",
|
|
2592
|
+
"cat",
|
|
2593
|
+
"list-units",
|
|
2594
|
+
"list-unit-files",
|
|
2595
|
+
"list-timers",
|
|
2596
|
+
"list-sockets",
|
|
2597
|
+
"list-dependencies",
|
|
2598
|
+
"list-jobs",
|
|
2599
|
+
"list-machines",
|
|
2600
|
+
"is-active",
|
|
2601
|
+
"is-enabled",
|
|
2602
|
+
"is-failed",
|
|
2603
|
+
"is-system-running",
|
|
2604
|
+
"get-default",
|
|
2605
|
+
"show-environment"
|
|
2606
|
+
])
|
|
2607
|
+
]
|
|
2608
|
+
]);
|
|
2609
|
+
var FLAG_CHANGES_CLASS = /* @__PURE__ */ new Map([
|
|
2610
|
+
[
|
|
2611
|
+
"find",
|
|
2612
|
+
new Map([
|
|
2613
|
+
// Runs anything, once per file found. A model that cannot get `bash`
|
|
2614
|
+
// past this file can get `find . -name x -exec bash {} ;` past it.
|
|
2615
|
+
...["-exec", "-execdir", "-ok", "-okdir"].map(
|
|
2616
|
+
(flag) => [flag, "interpreter"]
|
|
2617
|
+
),
|
|
2618
|
+
...["-delete", "-fprint", "-fprint0", "-fprintf", "-fls"].map(
|
|
2619
|
+
(flag) => [flag, "write"]
|
|
2620
|
+
)
|
|
2265
2621
|
])
|
|
2266
2622
|
],
|
|
2267
|
-
["
|
|
2268
|
-
|
|
2269
|
-
|
|
2623
|
+
["sort", /* @__PURE__ */ new Map([["-o", "write"], ["--output", "write"]])],
|
|
2624
|
+
/*
|
|
2625
|
+
`fd`, `rg` and `tree` were left in the read table with no entry here, and
|
|
2626
|
+
the first two run arbitrary programs exactly the way `find -exec` does.
|
|
2627
|
+
|
|
2628
|
+
`fd -x` IS `find -exec` under a newer name, and `rg --pre` runs a program
|
|
2629
|
+
per file to decode it. Both were classified `read` and therefore ran
|
|
2630
|
+
automatically: `fd --exec curl http://…` reached the network, which is the
|
|
2631
|
+
one refusal nothing is supposed to override. They were refused only when
|
|
2632
|
+
the helper was spelled with a slash — `pathOutsideRoots` catching
|
|
2633
|
+
`/bin/sh` — so naming it `sh`, or putting it inside the roots, evaporated
|
|
2634
|
+
the refusal. That is not a boundary, it is a coincidence about spelling.
|
|
2635
|
+
|
|
2636
|
+
`tree -o` writes a file, the same shape as `sort -o`.
|
|
2637
|
+
*/
|
|
2638
|
+
[
|
|
2639
|
+
"fd",
|
|
2640
|
+
new Map(
|
|
2641
|
+
["-x", "--exec", "-X", "--exec-batch"].map(
|
|
2642
|
+
(flag) => [flag, "interpreter"]
|
|
2643
|
+
)
|
|
2644
|
+
)
|
|
2645
|
+
],
|
|
2646
|
+
[
|
|
2647
|
+
"rg",
|
|
2648
|
+
new Map(
|
|
2649
|
+
["--pre", "--hostname-bin"].map((flag) => [flag, "interpreter"])
|
|
2650
|
+
)
|
|
2651
|
+
],
|
|
2652
|
+
["tree", /* @__PURE__ */ new Map([["-o", "write"]])],
|
|
2653
|
+
[
|
|
2654
|
+
"journalctl",
|
|
2655
|
+
new Map(
|
|
2656
|
+
[
|
|
2657
|
+
"--vacuum-size",
|
|
2658
|
+
"--vacuum-time",
|
|
2659
|
+
"--vacuum-files",
|
|
2660
|
+
"--rotate",
|
|
2661
|
+
"--flush",
|
|
2662
|
+
"--sync",
|
|
2663
|
+
"--relinquish-var",
|
|
2664
|
+
"--smart-relinquish-var",
|
|
2665
|
+
"--setup-keys",
|
|
2666
|
+
"--update-catalog"
|
|
2667
|
+
].map((flag) => [flag, "write"])
|
|
2668
|
+
)
|
|
2669
|
+
],
|
|
2670
|
+
[
|
|
2671
|
+
"dmesg",
|
|
2672
|
+
new Map(
|
|
2673
|
+
// `-c` reads AND clears, which is the one that costs somebody the
|
|
2674
|
+
// evidence they were reading the log to find.
|
|
2675
|
+
[
|
|
2676
|
+
"-C",
|
|
2677
|
+
"--clear",
|
|
2678
|
+
"-c",
|
|
2679
|
+
"--read-clear",
|
|
2680
|
+
"-D",
|
|
2681
|
+
"--console-off",
|
|
2682
|
+
"-E",
|
|
2683
|
+
"--console-on",
|
|
2684
|
+
"-n",
|
|
2685
|
+
"--console-level"
|
|
2686
|
+
].map((flag) => [flag, "write"])
|
|
2687
|
+
)
|
|
2688
|
+
]
|
|
2689
|
+
]);
|
|
2690
|
+
var REMOTE_FLAGS = /* @__PURE__ */ new Map([
|
|
2691
|
+
["docker", /* @__PURE__ */ new Set(["-H", "--host", "--context"])],
|
|
2692
|
+
["systemctl", /* @__PURE__ */ new Set(["-H", "--host"])]
|
|
2693
|
+
]);
|
|
2694
|
+
var ENDLESS_FLAGS = /* @__PURE__ */ new Map([
|
|
2695
|
+
["tail", /* @__PURE__ */ new Set(["-f", "-F", "--follow"])],
|
|
2696
|
+
["journalctl", /* @__PURE__ */ new Set(["-f", "--follow"])],
|
|
2697
|
+
["dmesg", /* @__PURE__ */ new Set(["-w", "--follow", "-W", "--follow-new"])],
|
|
2698
|
+
["docker logs", /* @__PURE__ */ new Set(["-f", "--follow"])],
|
|
2699
|
+
["kubectl logs", /* @__PURE__ */ new Set(["-f", "--follow"])]
|
|
2270
2700
|
]);
|
|
2271
2701
|
var MAX_OUTPUT_BYTES = 256 * 1024;
|
|
2272
2702
|
var TIMEOUT_MS = 2e4;
|
|
2703
|
+
var AFTER_KILL_MS = 1e3;
|
|
2273
2704
|
function describe2(argv) {
|
|
2274
2705
|
return argv.join(" ");
|
|
2275
2706
|
}
|
|
@@ -2277,15 +2708,73 @@ function programOf(argv) {
|
|
|
2277
2708
|
const first = argv[0] ?? "";
|
|
2278
2709
|
return first.split("/").pop() ?? first;
|
|
2279
2710
|
}
|
|
2711
|
+
function subcommandOf(argv) {
|
|
2712
|
+
return argv.slice(1).find((one) => !one.startsWith("-"));
|
|
2713
|
+
}
|
|
2714
|
+
function flagsIn(argv) {
|
|
2715
|
+
const found = /* @__PURE__ */ new Set();
|
|
2716
|
+
for (const argument of argv.slice(1)) {
|
|
2717
|
+
if (argument === "--") break;
|
|
2718
|
+
if (argument === "-" || !argument.startsWith("-")) continue;
|
|
2719
|
+
const name = argument.split("=")[0] ?? argument;
|
|
2720
|
+
found.add(name);
|
|
2721
|
+
if (!name.startsWith("--")) {
|
|
2722
|
+
for (const letter of name.slice(1)) found.add(`-${letter}`);
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
return found;
|
|
2726
|
+
}
|
|
2727
|
+
function anyFlag(argv, flags) {
|
|
2728
|
+
if (!flags) return void 0;
|
|
2729
|
+
for (const flag of flagsIn(argv)) {
|
|
2730
|
+
if (flags.has(flag)) return flag;
|
|
2731
|
+
}
|
|
2732
|
+
return void 0;
|
|
2733
|
+
}
|
|
2734
|
+
function keysFor(argv) {
|
|
2735
|
+
const program = programOf(argv);
|
|
2736
|
+
const sub = subcommandOf(argv);
|
|
2737
|
+
return sub ? [program, `${program} ${sub}`] : [program];
|
|
2738
|
+
}
|
|
2739
|
+
function remoteFlag(argv) {
|
|
2740
|
+
return anyFlag(argv, REMOTE_FLAGS.get(programOf(argv)));
|
|
2741
|
+
}
|
|
2742
|
+
function endlessFlag(argv) {
|
|
2743
|
+
for (const key of keysFor(argv)) {
|
|
2744
|
+
const found = anyFlag(argv, ENDLESS_FLAGS.get(key));
|
|
2745
|
+
if (found) return found;
|
|
2746
|
+
}
|
|
2747
|
+
return void 0;
|
|
2748
|
+
}
|
|
2749
|
+
function flagClass(argv) {
|
|
2750
|
+
for (const key of keysFor(argv)) {
|
|
2751
|
+
const table2 = FLAG_CHANGES_CLASS.get(key);
|
|
2752
|
+
if (!table2) continue;
|
|
2753
|
+
for (const flag of flagsIn(argv)) {
|
|
2754
|
+
const found = table2.get(flag);
|
|
2755
|
+
if (found) return found;
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
return void 0;
|
|
2759
|
+
}
|
|
2760
|
+
function runsAnotherProgram(argv) {
|
|
2761
|
+
if (programOf(argv) !== "env") return false;
|
|
2762
|
+
return argv.slice(1).some((one) => !one.startsWith("-") && !one.includes("="));
|
|
2763
|
+
}
|
|
2280
2764
|
function classify(argv, policy) {
|
|
2281
2765
|
const program = programOf(argv);
|
|
2766
|
+
if (remoteFlag(argv)) return "network";
|
|
2767
|
+
const imposed = flagClass(argv);
|
|
2768
|
+
if (imposed === "interpreter" || runsAnotherProgram(argv)) return "interpreter";
|
|
2769
|
+
const known = CLASSES.get(program);
|
|
2770
|
+
if (known === "network" || known === "interpreter") return known;
|
|
2282
2771
|
if (policy.allow.includes(program)) return "read";
|
|
2283
2772
|
const reads = SUBCOMMAND_READS.get(program);
|
|
2284
2773
|
if (reads) {
|
|
2285
|
-
const sub = argv
|
|
2774
|
+
const sub = subcommandOf(argv);
|
|
2286
2775
|
return sub && reads.has(sub) ? "read" : "write";
|
|
2287
2776
|
}
|
|
2288
|
-
return
|
|
2777
|
+
return imposed ?? known ?? "unknown";
|
|
2289
2778
|
}
|
|
2290
2779
|
function judge(argv, policy) {
|
|
2291
2780
|
const program = programOf(argv);
|
|
@@ -2302,21 +2791,32 @@ function judge(argv, policy) {
|
|
|
2302
2791
|
}
|
|
2303
2792
|
const commandClass = classify(argv, policy);
|
|
2304
2793
|
if (commandClass === "network") {
|
|
2794
|
+
const remote = remoteFlag(argv);
|
|
2305
2795
|
return {
|
|
2306
2796
|
commandClass,
|
|
2307
2797
|
automatic: false,
|
|
2308
|
-
refusal: `"${program}" can reach the network. This machine will not run it, and no approval enables it: a command that reads private files and can also send them is the one combination nobody can review by looking at it
|
|
2798
|
+
refusal: (remote ? `"${program} ${remote}" points at another machine, so it can reach the network. ` : `"${program}" can reach the network. `) + "This machine will not run it, and no approval enables it: a command that reads private files and can also send them is the one combination nobody can review by looking at it.",
|
|
2309
2799
|
reason: "reads private data and has a way out"
|
|
2310
2800
|
};
|
|
2311
2801
|
}
|
|
2312
2802
|
if (commandClass === "interpreter") {
|
|
2803
|
+
const language = CLASSES.get(program) === "interpreter";
|
|
2313
2804
|
return {
|
|
2314
2805
|
commandClass,
|
|
2315
2806
|
automatic: false,
|
|
2316
|
-
refusal: `"${program}" runs a language, which is every command at once. Ask for the specific command instead.`,
|
|
2807
|
+
refusal: language ? `"${program}" runs a language, which is every command at once. Ask for the specific command instead.` : `"${describe2(argv)}" runs a program of its own, which is every command at once. Ask for the specific command instead.`,
|
|
2317
2808
|
reason: "an interpreter is not one command"
|
|
2318
2809
|
};
|
|
2319
2810
|
}
|
|
2811
|
+
const endless = endlessFlag(argv);
|
|
2812
|
+
if (endless) {
|
|
2813
|
+
return {
|
|
2814
|
+
commandClass,
|
|
2815
|
+
automatic: false,
|
|
2816
|
+
refusal: `"${program} ${endless}" follows the log and never finishes, and nothing here streams \u2014 the reply is sent when the command exits. Ask for the end of the log instead: \`tail -n 500 <file>\`, \`journalctl -n 500 -u <unit>\`, \`docker logs --tail 500 <container>\`.`,
|
|
2817
|
+
reason: "a follow never produces an answer"
|
|
2818
|
+
};
|
|
2819
|
+
}
|
|
2320
2820
|
const outside = pathOutsideRoots(argv, policy.roots);
|
|
2321
2821
|
if (outside) {
|
|
2322
2822
|
return {
|
|
@@ -2340,35 +2840,47 @@ function judge(argv, policy) {
|
|
|
2340
2840
|
};
|
|
2341
2841
|
}
|
|
2342
2842
|
function pathOutsideRoots(argv, roots) {
|
|
2843
|
+
const realRoots = roots.map((root) => realLocation(resolvePath(root)));
|
|
2343
2844
|
for (const argument of argv.slice(1)) {
|
|
2344
|
-
|
|
2345
|
-
|
|
2845
|
+
const attached = /^-[A-Za-z]([/~.].*)$/.exec(argument)?.[1];
|
|
2846
|
+
const value = argument.startsWith("-") ? argument.includes("=") ? argument.slice(argument.indexOf("=") + 1) : attached ?? "" : argument;
|
|
2847
|
+
if (argument.startsWith("-") && !argument.includes("=") && attached === void 0) continue;
|
|
2848
|
+
if (!value.startsWith("/") && !value.startsWith("~") && !value.startsWith(".") && !value.includes("/")) {
|
|
2346
2849
|
continue;
|
|
2347
2850
|
}
|
|
2348
|
-
const
|
|
2349
|
-
if (!
|
|
2851
|
+
const real = realLocation(expand(value, roots[0] ?? process.cwd()));
|
|
2852
|
+
if (!realRoots.some((root) => contains(root, real))) {
|
|
2350
2853
|
return argument;
|
|
2351
2854
|
}
|
|
2352
2855
|
}
|
|
2353
2856
|
return void 0;
|
|
2354
2857
|
}
|
|
2858
|
+
function contains(root, path) {
|
|
2859
|
+
const rel = relative2(root, path);
|
|
2860
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
|
|
2861
|
+
}
|
|
2355
2862
|
function expand(argument, base) {
|
|
2356
2863
|
const home = process.env["HOME"] ?? "";
|
|
2357
2864
|
const withHome = argument.startsWith("~") ? join4(home, argument.slice(1)) : argument;
|
|
2358
2865
|
return isAbsolute2(withHome) ? resolvePath(withHome) : resolvePath(base, withHome);
|
|
2359
2866
|
}
|
|
2360
|
-
|
|
2867
|
+
function refusal(text) {
|
|
2868
|
+
return { ok: false, text, bytes: 0 };
|
|
2869
|
+
}
|
|
2870
|
+
async function runCommand(argv, policy, limits = {}) {
|
|
2871
|
+
const maxOutputBytes = limits.maxOutputBytes ?? MAX_OUTPUT_BYTES;
|
|
2872
|
+
const timeoutMs = limits.timeoutMs ?? TIMEOUT_MS;
|
|
2361
2873
|
const verdict = judge(argv, policy);
|
|
2362
|
-
if (verdict.refusal) return
|
|
2874
|
+
if (verdict.refusal) return refusal(verdict.refusal);
|
|
2363
2875
|
if (policy.mode === "plan") {
|
|
2364
|
-
return
|
|
2876
|
+
return refusal(`Plan mode: this machine did not run \`${describe2(argv)}\`.`);
|
|
2365
2877
|
}
|
|
2366
2878
|
const cwd = policy.roots[0];
|
|
2367
|
-
if (!cwd) return
|
|
2879
|
+
if (!cwd) return refusal("This machine has no folders it may read.");
|
|
2368
2880
|
try {
|
|
2369
|
-
if (!statSync2(cwd).isDirectory()) return
|
|
2881
|
+
if (!statSync2(cwd).isDirectory()) return refusal(`${cwd} is not a folder.`);
|
|
2370
2882
|
} catch {
|
|
2371
|
-
return
|
|
2883
|
+
return refusal(`${cwd} does not exist.`);
|
|
2372
2884
|
}
|
|
2373
2885
|
return new Promise((resolve8) => {
|
|
2374
2886
|
const child = spawn2(argv[0], argv.slice(1), {
|
|
@@ -2376,6 +2888,33 @@ async function runCommand(argv, policy) {
|
|
|
2376
2888
|
// NO shell. With one, every character a model can produce is a character
|
|
2377
2889
|
// the shell can act on, and the argument list stops meaning anything.
|
|
2378
2890
|
shell: false,
|
|
2891
|
+
/*
|
|
2892
|
+
ITS OWN PROCESS GROUP, so the timeout can kill everything it started.
|
|
2893
|
+
|
|
2894
|
+
A signal to one pid stops one process. `zcat` is a shell wrapper around
|
|
2895
|
+
`gzip` on most systems, a script spawns what it likes, and killing the
|
|
2896
|
+
parent leaves the child running and holding the stdout pipe — which is
|
|
2897
|
+
what `close` waits for, so the promise below waited too, and the loop
|
|
2898
|
+
with it. A negative pid signals the group.
|
|
2899
|
+
|
|
2900
|
+
The cost is that this child no longer sees the Ctrl-C that stops the
|
|
2901
|
+
agent, which is the right way round: the loop finishes the request it
|
|
2902
|
+
is holding, and a half-killed command is not a better answer.
|
|
2903
|
+
|
|
2904
|
+
Not on Windows, which has no process groups to signal; there the
|
|
2905
|
+
backstop below is the whole guarantee.
|
|
2906
|
+
*/
|
|
2907
|
+
detached: process.platform !== "win32",
|
|
2908
|
+
/*
|
|
2909
|
+
NO STDIN, which is a bound as much as the timeout is.
|
|
2910
|
+
|
|
2911
|
+
The default is a pipe nobody ever writes to, so anything that reads
|
|
2912
|
+
standard input — `cat` with no file, `grep` with a pattern and no path,
|
|
2913
|
+
a program that stops to ask something — waited for the full twenty
|
|
2914
|
+
seconds and came back empty, indistinguishable from a hang. There is
|
|
2915
|
+
nobody at a keyboard here. Closed, so those read EOF and exit at once.
|
|
2916
|
+
*/
|
|
2917
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2379
2918
|
/*
|
|
2380
2919
|
A bare environment.
|
|
2381
2920
|
|
|
@@ -2389,38 +2928,84 @@ async function runCommand(argv, policy) {
|
|
|
2389
2928
|
LANG: process.env["LANG"] ?? "C"
|
|
2390
2929
|
}
|
|
2391
2930
|
});
|
|
2392
|
-
|
|
2393
|
-
|
|
2931
|
+
const started = Date.now();
|
|
2932
|
+
const chunks = [];
|
|
2933
|
+
let bytes = 0;
|
|
2934
|
+
let lastOutput;
|
|
2935
|
+
let stopped;
|
|
2936
|
+
let settled = false;
|
|
2937
|
+
let backstop;
|
|
2938
|
+
const kill = () => {
|
|
2939
|
+
try {
|
|
2940
|
+
if (child.pid !== void 0 && process.platform !== "win32") {
|
|
2941
|
+
process.kill(-child.pid, "SIGKILL");
|
|
2942
|
+
} else {
|
|
2943
|
+
child.kill("SIGKILL");
|
|
2944
|
+
}
|
|
2945
|
+
} catch {
|
|
2946
|
+
}
|
|
2947
|
+
};
|
|
2948
|
+
const finished = (code) => {
|
|
2949
|
+
const elapsed = (Date.now() - started) / 1e3;
|
|
2950
|
+
const notes = [];
|
|
2951
|
+
if (stopped === "output") {
|
|
2952
|
+
notes.push(
|
|
2953
|
+
`[CUT OFF. This is the first ${bytes} bytes and this machine stopped the command there \u2014 there was more, and it is not below. For a log, ask for the end of it instead: \`tail -n 500 <file>\`, \`journalctl -n 500 -u <unit>\`.]`
|
|
2954
|
+
);
|
|
2955
|
+
}
|
|
2956
|
+
if (stopped === "time") {
|
|
2957
|
+
notes.push(
|
|
2958
|
+
`[STOPPED after ${elapsed.toFixed(1)}s. ` + (lastOutput === void 0 ? "It had produced nothing at all in that time" : `It had produced ${bytes} bytes, the last of them ${((lastOutput - started) / 1e3).toFixed(1)}s in`) + ", so this is a fragment of the answer rather than the answer.]"
|
|
2959
|
+
);
|
|
2960
|
+
}
|
|
2961
|
+
if (code !== null && code !== 0) notes.push(`[exit code ${code}]`);
|
|
2962
|
+
const head = notes.length > 0 ? `${notes.join("\n")}
|
|
2963
|
+
|
|
2964
|
+
` : "";
|
|
2965
|
+
const body = bytes === 0 ? "(no output)\n" : Buffer.concat(chunks).toString("utf8");
|
|
2966
|
+
return {
|
|
2967
|
+
// See `ok` on CommandOutcome: a non-zero exit with something to say is
|
|
2968
|
+
// an answer, and a silent failure is a sentence.
|
|
2969
|
+
ok: bytes > 0 || code === 0,
|
|
2970
|
+
text: `$ ${describe2(argv)}
|
|
2971
|
+
|
|
2972
|
+
${head}${body}`,
|
|
2973
|
+
bytes,
|
|
2974
|
+
...code !== null ? { exitCode: code } : {},
|
|
2975
|
+
...stopped ? { stopped } : {}
|
|
2976
|
+
};
|
|
2977
|
+
};
|
|
2978
|
+
const settle = (outcome) => {
|
|
2979
|
+
if (settled) return;
|
|
2980
|
+
settled = true;
|
|
2981
|
+
clearTimeout(timer);
|
|
2982
|
+
if (backstop) clearTimeout(backstop);
|
|
2983
|
+
resolve8(outcome);
|
|
2984
|
+
};
|
|
2985
|
+
const stop = (why) => {
|
|
2986
|
+
if (stopped) return;
|
|
2987
|
+
stopped = why;
|
|
2988
|
+
kill();
|
|
2989
|
+
backstop = setTimeout(() => settle(finished(null)), AFTER_KILL_MS);
|
|
2990
|
+
};
|
|
2394
2991
|
const collect = (chunk) => {
|
|
2395
|
-
if (
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2992
|
+
if (stopped) return;
|
|
2993
|
+
lastOutput = Date.now();
|
|
2994
|
+
const room = maxOutputBytes - bytes;
|
|
2995
|
+
if (chunk.length > room) {
|
|
2996
|
+
chunks.push(chunk.subarray(0, room));
|
|
2997
|
+
bytes += room;
|
|
2998
|
+
stop("output");
|
|
2999
|
+
return;
|
|
2401
3000
|
}
|
|
3001
|
+
chunks.push(chunk);
|
|
3002
|
+
bytes += chunk.length;
|
|
2402
3003
|
};
|
|
2403
3004
|
child.stdout.on("data", collect);
|
|
2404
3005
|
child.stderr.on("data", collect);
|
|
2405
|
-
const timer = setTimeout(() =>
|
|
2406
|
-
child.on("error", (error) => {
|
|
2407
|
-
|
|
2408
|
-
resolve8({ ok: false, text: `Could not run it: ${error.message}` });
|
|
2409
|
-
});
|
|
2410
|
-
child.on("close", (code) => {
|
|
2411
|
-
clearTimeout(timer);
|
|
2412
|
-
const notes = [
|
|
2413
|
-
truncated ? `
|
|
2414
|
-
|
|
2415
|
-
[output cut off at ${MAX_OUTPUT_BYTES} bytes]` : "",
|
|
2416
|
-
code !== 0 && code !== null ? `
|
|
2417
|
-
|
|
2418
|
-
[exit code ${code}]` : ""
|
|
2419
|
-
].join("");
|
|
2420
|
-
resolve8({ ok: code === 0, text: `$ ${describe2(argv)}
|
|
2421
|
-
|
|
2422
|
-
${output}${notes}` });
|
|
2423
|
-
});
|
|
3006
|
+
const timer = setTimeout(() => stop("time"), timeoutMs);
|
|
3007
|
+
child.on("error", (error) => settle(refusal(`Could not run it: ${error.message}`)));
|
|
3008
|
+
child.on("close", (code) => settle(finished(code)));
|
|
2424
3009
|
});
|
|
2425
3010
|
}
|
|
2426
3011
|
|
|
@@ -2521,19 +3106,7 @@ ${downloaded.length} bytes
|
|
|
2521
3106
|
try {
|
|
2522
3107
|
const stats = statSync3(located);
|
|
2523
3108
|
if (!stats.isDirectory()) return { ok: false, error: `${request.path} is not a folder.` };
|
|
2524
|
-
|
|
2525
|
-
if (entry.isDirectory()) return `${entry.name}/`;
|
|
2526
|
-
try {
|
|
2527
|
-
return `${entry.name} ${sizeOf(join5(located, entry.name))}`;
|
|
2528
|
-
} catch {
|
|
2529
|
-
return entry.name;
|
|
2530
|
-
}
|
|
2531
|
-
}).sort();
|
|
2532
|
-
const listing = entries.length > 0 ? entries.join("\n") : "(empty)";
|
|
2533
|
-
bytes = Buffer.from(`${request.path}
|
|
2534
|
-
|
|
2535
|
-
${listing}
|
|
2536
|
-
`, "utf8");
|
|
3109
|
+
bytes = Buffer.from(folderListing(request.path, located), "utf8");
|
|
2537
3110
|
filename = `${request.path.split("/").filter(Boolean).pop() ?? "listing"}.txt`;
|
|
2538
3111
|
const grant = await upload(apiUrl, token, filename, bytes);
|
|
2539
3112
|
return grant;
|
|
@@ -2585,6 +3158,25 @@ async function upload(apiUrl, token, filename, bytes) {
|
|
|
2585
3158
|
return { ok: true, attachToken: stored.attachToken, bytes: bytes.length };
|
|
2586
3159
|
}
|
|
2587
3160
|
var MAX_LISTED = 200;
|
|
3161
|
+
function folderListing(requested, located) {
|
|
3162
|
+
const all = readdirSync(located, { withFileTypes: true }).filter((entry) => !entry.name.startsWith(".")).sort((a, b) => a.name.localeCompare(b.name));
|
|
3163
|
+
const shown = all.slice(0, MAX_LISTED).map((entry) => {
|
|
3164
|
+
if (entry.isDirectory()) return `${entry.name}/`;
|
|
3165
|
+
try {
|
|
3166
|
+
return `${entry.name} ${sizeOf(join5(located, entry.name))}`;
|
|
3167
|
+
} catch {
|
|
3168
|
+
return entry.name;
|
|
3169
|
+
}
|
|
3170
|
+
});
|
|
3171
|
+
const listing = shown.length > 0 ? shown.join("\n") : "(empty)";
|
|
3172
|
+
const rest = all.length > shown.length ? `
|
|
3173
|
+
|
|
3174
|
+
[\u2026${all.length - shown.length} more entries not listed: this is the first ${MAX_LISTED} by name, not the whole folder]` : "";
|
|
3175
|
+
return `${requested}
|
|
3176
|
+
|
|
3177
|
+
${listing}${rest}
|
|
3178
|
+
`;
|
|
3179
|
+
}
|
|
2588
3180
|
function sizeOf(path) {
|
|
2589
3181
|
const size = statSync3(path).size;
|
|
2590
3182
|
if (size < 1024) return `${size} B`;
|
|
@@ -2680,7 +3272,9 @@ async function agentCommand(context) {
|
|
|
2680
3272
|
} else {
|
|
2681
3273
|
const { items } = await claimed.json();
|
|
2682
3274
|
for (const request of items) {
|
|
2683
|
-
context.print(
|
|
3275
|
+
context.print(
|
|
3276
|
+
request.kind === "run_command" ? `Running ${request.path}` : `Reading ${request.path}`
|
|
3277
|
+
);
|
|
2684
3278
|
const outcome = await answer(context, apiUrl, await authorization(), roots, request);
|
|
2685
3279
|
const done = await call(
|
|
2686
3280
|
`complete/${encodeURIComponent(request.id)}`,
|
|
@@ -2866,7 +3460,7 @@ async function mailCommand(context) {
|
|
|
2866
3460
|
|
|
2867
3461
|
// src/commands/requests.ts
|
|
2868
3462
|
import { existsSync as existsSync6, writeFileSync as writeFileSync6 } from "node:fs";
|
|
2869
|
-
import { resolve as resolve5 } from "node:path";
|
|
3463
|
+
import { basename as basename3, resolve as resolve5 } from "node:path";
|
|
2870
3464
|
async function requestsCommand(context) {
|
|
2871
3465
|
if (context.args.words[1] === "get") return collectCommand(context);
|
|
2872
3466
|
const credential = context.resolved.credential;
|
|
@@ -2933,8 +3527,7 @@ async function collectCommand(context) {
|
|
|
2933
3527
|
context.error(`The download refused it (${file.status}). Links expire in minutes \u2014 try again.`);
|
|
2934
3528
|
return 1;
|
|
2935
3529
|
}
|
|
2936
|
-
const
|
|
2937
|
-
const target = resolve5(name);
|
|
3530
|
+
const target = downloadTarget(filename, stringFlag(context.args, "output", "o"));
|
|
2938
3531
|
if (existsSync6(target)) {
|
|
2939
3532
|
context.error(`${target} already exists. Pass --output to write somewhere else.`);
|
|
2940
3533
|
return 1;
|
|
@@ -2943,6 +3536,9 @@ async function collectCommand(context) {
|
|
|
2943
3536
|
context.print(`Wrote ${target}`);
|
|
2944
3537
|
return 0;
|
|
2945
3538
|
}
|
|
3539
|
+
function downloadTarget(filename, output) {
|
|
3540
|
+
return resolve5(output ?? (basename3(filename) || "file"));
|
|
3541
|
+
}
|
|
2946
3542
|
|
|
2947
3543
|
// src/workspace.ts
|
|
2948
3544
|
import { existsSync as existsSync7, readFileSync as readFileSync7, writeFileSync as writeFileSync7 } from "node:fs";
|
|
@@ -3360,7 +3956,7 @@ function processPath() {
|
|
|
3360
3956
|
// src/commands/session.ts
|
|
3361
3957
|
import { createInterface as createInterface2 } from "node:readline";
|
|
3362
3958
|
import { randomUUID } from "node:crypto";
|
|
3363
|
-
import { relative as
|
|
3959
|
+
import { relative as relative3 } from "node:path";
|
|
3364
3960
|
|
|
3365
3961
|
// src/events.ts
|
|
3366
3962
|
import { appendFileSync, existsSync as existsSync9, mkdirSync as mkdirSync3, readFileSync as readFileSync8 } from "node:fs";
|
|
@@ -3532,11 +4128,11 @@ async function handleInput(args) {
|
|
|
3532
4128
|
path: file.path,
|
|
3533
4129
|
bytes: file.bytes
|
|
3534
4130
|
});
|
|
3535
|
-
context.print(` read ${
|
|
4131
|
+
context.print(` read ${relative3(root, file.path)} (${Math.round(file.bytes / 1024)} KB)`);
|
|
3536
4132
|
if (command === "capture") {
|
|
3537
4133
|
const client = await context.client();
|
|
3538
4134
|
const result = await client.memories.remember(
|
|
3539
|
-
{ text: file.text, title:
|
|
4135
|
+
{ text: file.text, title: relative3(root, file.path) },
|
|
3540
4136
|
{ idempotencyKey: `cli:capture:${file.path}:${file.bytes}` }
|
|
3541
4137
|
);
|
|
3542
4138
|
log.append({
|
|
@@ -3640,7 +4236,185 @@ async function write2(args) {
|
|
|
3640
4236
|
return;
|
|
3641
4237
|
}
|
|
3642
4238
|
commitWrite(proposed, true);
|
|
3643
|
-
context.print(` Wrote ${
|
|
4239
|
+
context.print(` Wrote ${relative3(root, proposed.path)}.`);
|
|
4240
|
+
}
|
|
4241
|
+
|
|
4242
|
+
// src/commands/sharing.ts
|
|
4243
|
+
var collaboratorColumns = [
|
|
4244
|
+
{ header: "email", value: (one) => one.email },
|
|
4245
|
+
{ header: "name", value: (one) => one.name ?? "" },
|
|
4246
|
+
{ header: "role", value: (one) => one.role },
|
|
4247
|
+
// The column that matters. See `standing`.
|
|
4248
|
+
{ header: "access", value: (one) => standing(one) },
|
|
4249
|
+
{ header: "invited by", value: (one) => one.invitedBy ?? "" }
|
|
4250
|
+
];
|
|
4251
|
+
function standing(one) {
|
|
4252
|
+
return one.acceptedAt ? `yes, since ${one.acceptedAt.slice(0, 10)}` : "no \u2014 invitation not accepted";
|
|
4253
|
+
}
|
|
4254
|
+
function sharingArgs(context) {
|
|
4255
|
+
const words = context.args.words.slice(2);
|
|
4256
|
+
const at = words.findIndex((one) => one.includes("@"));
|
|
4257
|
+
const email = at >= 0 ? words[at] ?? "" : "";
|
|
4258
|
+
const named = at > 0 ? words.slice(0, at).join(" ") : "";
|
|
4259
|
+
const trailing = at >= 0 ? words[at + 1] : void 0;
|
|
4260
|
+
return { named, email, role: stringFlag(context.args, "role") ?? trailing };
|
|
4261
|
+
}
|
|
4262
|
+
var ROLES = ["viewer", "editor"];
|
|
4263
|
+
function isRole(value) {
|
|
4264
|
+
return value !== void 0 && ROLES.includes(value);
|
|
4265
|
+
}
|
|
4266
|
+
async function resolveSpace(context, named) {
|
|
4267
|
+
const client = await context.client();
|
|
4268
|
+
const { data } = await client.spaces.list({ limit: 200 }).first();
|
|
4269
|
+
if (named.startsWith("space_")) {
|
|
4270
|
+
const byId = data.find((one) => one.id === named);
|
|
4271
|
+
if (byId) return byId;
|
|
4272
|
+
context.error(`No Space with id ${named}. Run \`pm spaces list\` to see yours.`);
|
|
4273
|
+
return 1;
|
|
4274
|
+
}
|
|
4275
|
+
const wanted2 = named.trim().toLowerCase();
|
|
4276
|
+
const matches = data.filter((one) => one.name.trim().toLowerCase() === wanted2);
|
|
4277
|
+
const only = matches[0];
|
|
4278
|
+
if (matches.length === 1 && only) return only;
|
|
4279
|
+
if (matches.length === 0) {
|
|
4280
|
+
context.error(`No Space called "${named}". Run \`pm spaces list\` to see yours.`);
|
|
4281
|
+
return 1;
|
|
4282
|
+
}
|
|
4283
|
+
context.error(
|
|
4284
|
+
`More than one Space is called "${named}": ${matches.map((one) => one.id).join(", ")}.`
|
|
4285
|
+
);
|
|
4286
|
+
context.error("Name it by id \u2014 sharing the wrong one gives somebody the wrong memories.");
|
|
4287
|
+
return 1;
|
|
4288
|
+
}
|
|
4289
|
+
async function shareSpaceCommand(context) {
|
|
4290
|
+
const { named, email, role } = sharingArgs(context);
|
|
4291
|
+
if (named === "" || email === "") {
|
|
4292
|
+
context.error("Which Space, and who?");
|
|
4293
|
+
context.error("");
|
|
4294
|
+
context.error(' pm spaces share "Work" priya@example.com --role viewer');
|
|
4295
|
+
return 2;
|
|
4296
|
+
}
|
|
4297
|
+
if (!isRole(role)) {
|
|
4298
|
+
context.error("Say what they may do \u2014 there is no default:");
|
|
4299
|
+
context.error("");
|
|
4300
|
+
context.error(` pm spaces share "${named}" ${email} --role viewer read everything in it`);
|
|
4301
|
+
context.error(` pm spaces share "${named}" ${email} --role editor read it, and add to it`);
|
|
4302
|
+
if (role !== void 0) {
|
|
4303
|
+
context.error("");
|
|
4304
|
+
context.error(
|
|
4305
|
+
role.toLowerCase() === "owner" ? "A Space cannot be handed over. Its owner is whoever created it." : `Roles are viewer and editor. "${role}" is neither.`
|
|
4306
|
+
);
|
|
4307
|
+
}
|
|
4308
|
+
return 2;
|
|
4309
|
+
}
|
|
4310
|
+
const space = await resolveSpace(context, named);
|
|
4311
|
+
if (typeof space === "number") return space;
|
|
4312
|
+
const client = await context.client();
|
|
4313
|
+
const invitation = await client.spaces.share(
|
|
4314
|
+
space.id,
|
|
4315
|
+
{ email, role },
|
|
4316
|
+
{
|
|
4317
|
+
/*
|
|
4318
|
+
Derived from what is being shared, not random.
|
|
4319
|
+
|
|
4320
|
+
The SDK will not retry a POST without a key, and a random one per
|
|
4321
|
+
attempt would defeat the point: a share that timed out after the
|
|
4322
|
+
server recorded it would send a second invitation to a real person's
|
|
4323
|
+
inbox. Same Space, same address, one invitation.
|
|
4324
|
+
*/
|
|
4325
|
+
idempotencyKey: `cli:share:${space.id}:${email}`
|
|
4326
|
+
}
|
|
4327
|
+
);
|
|
4328
|
+
if (context.flags.output !== "table") {
|
|
4329
|
+
context.print(renderOne(invitation, collaboratorColumns, { format: context.flags.output }));
|
|
4330
|
+
return 0;
|
|
4331
|
+
}
|
|
4332
|
+
context.print(`Invited ${invitation.email} to "${space.name}" as ${invitation.role}.`);
|
|
4333
|
+
context.print("");
|
|
4334
|
+
context.print(
|
|
4335
|
+
`They will see everything filed in "${space.name}" \u2014 including memories added later.`
|
|
4336
|
+
);
|
|
4337
|
+
context.print("Nothing is shared yet: they have to accept the invitation first.");
|
|
4338
|
+
if (invitation.role === "owner") {
|
|
4339
|
+
context.print("");
|
|
4340
|
+
context.print("As an owner they can share it onward and revoke anybody, including you.");
|
|
4341
|
+
}
|
|
4342
|
+
context.print("");
|
|
4343
|
+
context.print(`Undo it with: pm spaces unshare "${space.name}" ${invitation.email}`);
|
|
4344
|
+
return 0;
|
|
4345
|
+
}
|
|
4346
|
+
async function unshareSpaceCommand(context) {
|
|
4347
|
+
const { named, email } = sharingArgs(context);
|
|
4348
|
+
if (named === "" || email === "") {
|
|
4349
|
+
context.error("Which Space, and who?");
|
|
4350
|
+
context.error("");
|
|
4351
|
+
context.error(' pm spaces unshare "Work" priya@example.com');
|
|
4352
|
+
return 2;
|
|
4353
|
+
}
|
|
4354
|
+
const space = await resolveSpace(context, named);
|
|
4355
|
+
if (typeof space === "number") return space;
|
|
4356
|
+
const client = await context.client();
|
|
4357
|
+
const { email: ended } = await client.spaces.unshare(space.id, email);
|
|
4358
|
+
if (context.flags.output !== "table") {
|
|
4359
|
+
context.print(JSON.stringify({ spaceId: space.id, email: ended }, void 0, 2));
|
|
4360
|
+
return 0;
|
|
4361
|
+
}
|
|
4362
|
+
context.print(`${ended} can no longer see "${space.name}".`);
|
|
4363
|
+
context.print("Nothing of it was ever copied into their account, so nothing of it remains.");
|
|
4364
|
+
return 0;
|
|
4365
|
+
}
|
|
4366
|
+
async function spaceRoleCommand(context) {
|
|
4367
|
+
const { named, email, role } = sharingArgs(context);
|
|
4368
|
+
if (named === "" || email === "" || !isRole(role)) {
|
|
4369
|
+
context.error("Which Space, who, and what to:");
|
|
4370
|
+
context.error("");
|
|
4371
|
+
context.error(' pm spaces role "Work" priya@example.com editor');
|
|
4372
|
+
context.error("");
|
|
4373
|
+
context.error(`Roles: ${ROLES.join(", ")}. This changes somebody who already has access \u2014`);
|
|
4374
|
+
context.error("use `pm spaces share` to invite a new person.");
|
|
4375
|
+
return 2;
|
|
4376
|
+
}
|
|
4377
|
+
const space = await resolveSpace(context, named);
|
|
4378
|
+
if (typeof space === "number") return space;
|
|
4379
|
+
const client = await context.client();
|
|
4380
|
+
const changed = await client.spaces.setRole(space.id, { email, role });
|
|
4381
|
+
if (context.flags.output !== "table") {
|
|
4382
|
+
context.print(renderOne(changed, collaboratorColumns, { format: context.flags.output }));
|
|
4383
|
+
return 0;
|
|
4384
|
+
}
|
|
4385
|
+
context.print(`${changed.email} is now ${changed.role} on "${space.name}".`);
|
|
4386
|
+
if (changed.role === "owner") {
|
|
4387
|
+
context.print("As an owner they can share it onward and revoke anybody, including you.");
|
|
4388
|
+
}
|
|
4389
|
+
return 0;
|
|
4390
|
+
}
|
|
4391
|
+
async function spaceSharingCommand(context) {
|
|
4392
|
+
const named = context.args.words.slice(2).join(" ").trim();
|
|
4393
|
+
if (named === "") {
|
|
4394
|
+
context.error('Which Space? Try `pm spaces sharing "Work"`.');
|
|
4395
|
+
return 2;
|
|
4396
|
+
}
|
|
4397
|
+
const space = await resolveSpace(context, named);
|
|
4398
|
+
if (typeof space === "number") return space;
|
|
4399
|
+
const client = await context.client();
|
|
4400
|
+
const { data } = await client.spaces.collaborators(space.id, { limit: 200 }).first();
|
|
4401
|
+
if (context.flags.output !== "table") {
|
|
4402
|
+
context.print(render(data, collaboratorColumns, { format: context.flags.output }));
|
|
4403
|
+
return 0;
|
|
4404
|
+
}
|
|
4405
|
+
if (data.length === 0) {
|
|
4406
|
+
context.print(`Nobody else can see "${space.name}". It has never been shared.`);
|
|
4407
|
+
return 0;
|
|
4408
|
+
}
|
|
4409
|
+
context.print(render(data, collaboratorColumns, { format: context.flags.output }));
|
|
4410
|
+
const waiting = data.filter((one) => one.acceptedAt === void 0).length;
|
|
4411
|
+
if (waiting > 0) {
|
|
4412
|
+
context.print("");
|
|
4413
|
+
context.print(
|
|
4414
|
+
`${waiting} ${waiting === 1 ? "invitation has" : "invitations have"} not been accepted. ${waiting === 1 ? "That person can" : "Those people can"} see nothing yet.`
|
|
4415
|
+
);
|
|
4416
|
+
}
|
|
4417
|
+
return 0;
|
|
3644
4418
|
}
|
|
3645
4419
|
|
|
3646
4420
|
// src/commands/memory.ts
|
|
@@ -4112,8 +4886,14 @@ async function dispatch(context) {
|
|
|
4112
4886
|
if (noun === "create" || noun === "new") return createSpaceCommand(context);
|
|
4113
4887
|
if (noun === "delete" || noun === "remove") return deleteSpaceCommand(context);
|
|
4114
4888
|
if (noun === "merge") return mergeSpacesCommand(context);
|
|
4889
|
+
if (noun === "share") return shareSpaceCommand(context);
|
|
4890
|
+
if (noun === "unshare") return unshareSpaceCommand(context);
|
|
4891
|
+
if (noun === "role") return spaceRoleCommand(context);
|
|
4892
|
+
if (noun === "sharing") return spaceSharingCommand(context);
|
|
4115
4893
|
if (noun === void 0 || noun === "list") return listSpacesCommand(context);
|
|
4116
|
-
context.error(
|
|
4894
|
+
context.error(
|
|
4895
|
+
`Cannot "pm spaces ${noun}". Try list, create, delete, merge, share, unshare, role or sharing.`
|
|
4896
|
+
);
|
|
4117
4897
|
return 2;
|
|
4118
4898
|
case "list":
|
|
4119
4899
|
if (noun === "memories" || noun === "memory") return listMemoriesCommand(context);
|