cloudtifact 0.2.3 → 0.2.4

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.
Files changed (2) hide show
  1. package/dist/index.js +733 -25
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import fs, { createWriteStream, existsSync, lstatSync, realpathSync, statSync, watch } from "node:fs";
2
+ import fs, { createWriteStream, existsSync, lstatSync, readFileSync, realpathSync, statSync, watch } from "node:fs";
3
3
  import { chmod, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
@@ -6013,6 +6013,18 @@ const auditActionSchema = _enum([
6013
6013
  "space.updated",
6014
6014
  "space.moved",
6015
6015
  "space.deleted",
6016
+ "space.archived",
6017
+ "space.unarchived",
6018
+ "space.trashed",
6019
+ "space.restored",
6020
+ "page.added",
6021
+ "space.filed",
6022
+ "folder.created",
6023
+ "folder.renamed",
6024
+ "folder.moved",
6025
+ "folder.deleted",
6026
+ "folder.restored",
6027
+ "space.merged",
6016
6028
  "space.link_changed",
6017
6029
  "space.person_added",
6018
6030
  "space.person_changed",
@@ -6045,6 +6057,132 @@ const listAuditOutput = object({
6045
6057
  next: number$1().nullable(),
6046
6058
  nextId: string().nullable().optional()
6047
6059
  });
6060
+ /**
6061
+ * GET /v1/live-spaces, read only for what the CLI shows (Shared with me): the spaces you can open, in your workspaces
6062
+ * and the ones people added you to elsewhere. The full shape is in cloud-contract.
6063
+ */
6064
+ const listOpenableSpacesOutput = object({ liveSpaces: array(object({
6065
+ id: string(),
6066
+ name: string(),
6067
+ workspace: workspaceSchema,
6068
+ url: string(),
6069
+ createdBy: string().nullable().optional(),
6070
+ lastActivityAt: number$1().nullable()
6071
+ })) });
6072
+ const activityActionSchema = _enum([
6073
+ "space.created",
6074
+ "space.archived",
6075
+ "space.unarchived",
6076
+ "space.trashed",
6077
+ "space.restored",
6078
+ "page.added",
6079
+ "space.filed",
6080
+ "folder.created",
6081
+ "folder.renamed",
6082
+ "folder.moved",
6083
+ "folder.deleted",
6084
+ "folder.restored",
6085
+ "space.merged"
6086
+ ]);
6087
+ const activityFolderSchema = object({
6088
+ id: string(),
6089
+ name: string()
6090
+ });
6091
+ const activityEventSchema = object({
6092
+ id: string(),
6093
+ action: activityActionSchema,
6094
+ actor: userSchema.nullable(),
6095
+ via: string().nullable(),
6096
+ /** The space it was done to, name as it reads now. Null only for folder.* events (v=2). */
6097
+ space: object({
6098
+ id: string(),
6099
+ name: string()
6100
+ }).nullable(),
6101
+ /** page.added: the page's folder in the space ("pricing-d/") and its title. */
6102
+ page: object({
6103
+ path: string(),
6104
+ title: string().nullable()
6105
+ }).nullable(),
6106
+ /** folder.*: the folder, name as it reads now (folder.renamed: the name it got). */
6107
+ folder: activityFolderSchema.nullable().optional(),
6108
+ /**
6109
+ * space.filed and folder.moved: where it was and where it went (null: the top level). folder.renamed: the names.
6110
+ * space.merged: the space merged in.
6111
+ */
6112
+ from: activityFolderSchema.nullable().optional(),
6113
+ to: activityFolderSchema.nullable().optional(),
6114
+ /** Set when this was itself an Undo: the id of the event it undid. */
6115
+ undoes: string().nullable(),
6116
+ /** Whether the caller can undo it now: it's still the way it left things, and they may do the opposite. */
6117
+ canUndo: boolean(),
6118
+ /** Someone already undid it (an event below it in the list says so). */
6119
+ undone: boolean(),
6120
+ createdAt: number$1()
6121
+ });
6122
+ object({
6123
+ /** Only what this person did (their handle). */
6124
+ person: string().max(64).optional(),
6125
+ before: number().int().positive().optional(),
6126
+ beforeId: string().max(64).optional(),
6127
+ limit: number().int().min(1).max(100).default(50),
6128
+ /** 2: include folder events (space.filed, folder.*), whose space may be null. Omitted: only ACTIVITY_V1_ACTIONS. */
6129
+ v: number().int().optional()
6130
+ });
6131
+ const listActivityOutput = object({
6132
+ events: array(activityEventSchema),
6133
+ next: number$1().nullable(),
6134
+ nextId: string().nullable()
6135
+ });
6136
+ const undoActivityOutput = object({ event: activityEventSchema });
6137
+ /** "archived “Q4 board pack”", for "Jack (with Claude) archived “Q4 board pack”" (byPerson). */
6138
+ function describeActivity(e) {
6139
+ const name = `“${e.space?.name ?? "a space"}”`;
6140
+ const folder = `the folder “${e.folder?.name ?? "folder"}”`;
6141
+ const place = (f) => f ? `“${f.name}”` : "the top level";
6142
+ switch (e.action) {
6143
+ case "space.created": return `made ${name}`;
6144
+ case "space.archived": return `archived ${name}`;
6145
+ case "space.unarchived": return `brought ${name} back from the archive`;
6146
+ case "space.trashed": return `moved ${name} to the Trash`;
6147
+ case "space.restored": return `restored ${name} from the Trash`;
6148
+ case "page.added": return e.undoes ? `took the page “${e.page?.title ?? e.page?.path ?? "page"}” out of ${name}` : `added the page “${e.page?.title ?? e.page?.path ?? "page"}” to ${name}`;
6149
+ case "space.filed": return e.to ? `moved ${name} to “${e.to.name}”` : `moved ${name} to the top level`;
6150
+ case "folder.created": return `made ${folder}`;
6151
+ case "folder.renamed": return `renamed the folder “${e.from?.name ?? "folder"}” to “${e.to?.name ?? e.folder?.name ?? "folder"}”`;
6152
+ case "folder.moved": return e.to ? `moved ${folder} into ${place(e.to)}` : `moved ${folder} to the top level`;
6153
+ case "folder.deleted": return `removed ${folder} (what was in it moved up a level)`;
6154
+ case "folder.restored": return `brought back ${folder}`;
6155
+ case "space.merged": return e.undoes ? `took “${e.from?.name ?? "a space"}” back out of ${name}` : `merged “${e.from?.name ?? "a space"}” into ${name} as a page`;
6156
+ }
6157
+ }
6158
+ /** A folder's name: one line, no "/" (paths like "Product plans/Pricing" are names joined by "/"). */
6159
+ const folderNameSchema = string().trim().min(1).max(80).refine((n) => !n.includes("/"), "a folder name can't have a / in it");
6160
+ const folderDescriptionSchema = string().trim().max(300);
6161
+ const folderSchema = object({
6162
+ id: string(),
6163
+ parentId: string().nullable(),
6164
+ name: string(),
6165
+ /** One line people and agents read before filing something here. */
6166
+ description: string().nullable(),
6167
+ /** Its names from the top, joined by "/": "Product plans/Pricing". */
6168
+ path: string(),
6169
+ /** Spaces directly inside it that aren't archived or in the Trash. */
6170
+ spaces: number$1().int(),
6171
+ createdAt: number$1()
6172
+ });
6173
+ const listFoldersOutput = object({ folders: array(folderSchema) });
6174
+ object({
6175
+ name: folderNameSchema,
6176
+ parentId: string().max(64).nullable().optional(),
6177
+ description: folderDescriptionSchema.nullable().optional()
6178
+ });
6179
+ object({
6180
+ name: folderNameSchema.optional(),
6181
+ description: folderDescriptionSchema.nullable().optional(),
6182
+ /** Move it inside another folder (null: to the top level). A folder can't go inside itself. */
6183
+ parentId: string().max(64).nullable().optional()
6184
+ });
6185
+ object({ folderId: string().max(64).nullable() });
6048
6186
  const viewersSchema = object({
6049
6187
  files: boolean(),
6050
6188
  history: boolean(),
@@ -6084,12 +6222,55 @@ const spaceSchema = object({
6084
6222
  createdAt: number$1(),
6085
6223
  createdBy: string().optional(),
6086
6224
  updatedAt: number$1().nullable().optional(),
6087
- snapshotUrl: string().nullable().optional()
6225
+ snapshotUrl: string().nullable().optional(),
6226
+ archivedAt: number$1().nullable().optional(),
6227
+ /** The project its latest version belongs to, when the client said (versionSchema.project). */
6228
+ project: string().nullable().optional(),
6229
+ deletedAt: number$1().nullable().optional(),
6230
+ favorite: number$1().int().nullable().optional(),
6231
+ openedAt: number$1().nullable().optional(),
6232
+ /** The folder it's listed in (folderSchema), or null: the workspace's top level. Missing from older servers. */
6233
+ folderId: string().nullable().optional(),
6234
+ /** Tidy up merged it into another space as a page: that space and the page's folder there. Its links open it. */
6235
+ mergedInto: object({
6236
+ id: string(),
6237
+ page: string()
6238
+ }).nullable().optional()
6239
+ });
6240
+ const tidySpaceSchema = object({
6241
+ id: string(),
6242
+ name: string(),
6243
+ /** The latest version (0: nothing published yet) and when anything last changed. */
6244
+ latestVersion: number$1().int(),
6245
+ updatedAt: number$1(),
6246
+ /** Can go into another space as a page: no comments, uploads or data of its own that the page would leave behind. */
6247
+ canMerge: boolean()
6248
+ });
6249
+ const tidyOutput = object({
6250
+ lookAlikes: array(object({
6251
+ /** What they share, as people read it ("Pricing proposal"). */
6252
+ topic: string(),
6253
+ /** The space the others go into: the first one made, so its link (and its comments) stay. */
6254
+ into: tidySpaceSchema,
6255
+ spaces: array(tidySpaceSchema)
6256
+ })),
6257
+ finished: array(object({
6258
+ space: tidySpaceSchema,
6259
+ /** "Nothing new in 45 days", "Empty for 9 days". */
6260
+ why: string()
6261
+ }))
6262
+ });
6263
+ object({ into: string().max(64) });
6264
+ const mergeSpaceOutput = object({
6265
+ space: spaceSchema,
6266
+ page: string()
6088
6267
  });
6089
6268
  object({
6090
6269
  workspace: workspaceSlugSchema,
6091
6270
  name: string().min(1).max(100),
6092
- visibility: visibilitySchema.default("public")
6271
+ visibility: visibilitySchema.default("public"),
6272
+ /** List it in this folder of the workspace (folderSchema.id); omitted: the top level. */
6273
+ folderId: string().max(64).optional()
6093
6274
  });
6094
6275
  object({
6095
6276
  name: string().min(1).max(100).optional(),
@@ -6178,8 +6359,35 @@ const visitorsOutput = object({
6178
6359
  referrer: string().nullable()
6179
6360
  }))
6180
6361
  });
6181
- object({ workspace: workspaceSlugSchema.optional() });
6362
+ const spaceStatusSchema = _enum([
6363
+ "active",
6364
+ "archived",
6365
+ "trash"
6366
+ ]);
6367
+ object({
6368
+ workspace: workspaceSlugSchema.optional(),
6369
+ status: spaceStatusSchema.default("active")
6370
+ });
6371
+ object({ spaces: array(spaceIdSchema).max(500) });
6372
+ const favoritesOutput = object({ spaces: array(spaceIdSchema) });
6182
6373
  const listSpacesOutput = object({ spaces: array(spaceSchema) });
6374
+ object({
6375
+ q: string().trim().min(1).max(200),
6376
+ workspace: workspaceSlugSchema.optional(),
6377
+ limit: number().int().min(1).max(50).default(10)
6378
+ });
6379
+ const findSpacesOutput = object({ spaces: array(spaceSchema.extend({
6380
+ /** Pages (HTML and Markdown files) in its latest version. */
6381
+ pages: number$1().int(),
6382
+ /** 0 to 1. */
6383
+ score: number$1(),
6384
+ /** Close enough to publish into without being told (topicMatch in find.ts). */
6385
+ strong: boolean(),
6386
+ /** The page whose title matched better than the space's name, if any. */
6387
+ page: string().nullable()
6388
+ })) });
6389
+ /** A project's name as a client sends it: the repo folder's name or what the person typed. */
6390
+ const projectNameSchema = string().trim().min(1).max(100);
6183
6391
  const MAX_FILE_BYTES = 16777216;
6184
6392
  const manifest = array(object({
6185
6393
  path: filePathSchema,
@@ -6191,7 +6399,15 @@ const diffSchema = object({
6191
6399
  changed: array(string()),
6192
6400
  removed: array(string())
6193
6401
  });
6194
- object({ files: manifest });
6402
+ /**
6403
+ * A folder inside the space ("pricing-d/") that these files replace, keeping every file outside it: how a page is
6404
+ * added to a space that holds other pages. Every path must start with it. Omitted: the files are the whole space.
6405
+ */
6406
+ const pushPrefixSchema = string().max(100).regex(/^[a-z0-9][a-z0-9._-]*(\/[a-z0-9][a-z0-9._-]*)*\/$/, "a folder like pricing-d/, lowercase, ending in /");
6407
+ object({
6408
+ files: manifest,
6409
+ prefix: pushPrefixSchema.optional()
6410
+ });
6195
6411
  const pushPlanOutput = object({
6196
6412
  baseVersion: number$1().int(),
6197
6413
  missing: array(sha256Schema),
@@ -6200,12 +6416,18 @@ const pushPlanOutput = object({
6200
6416
  object({
6201
6417
  files: manifest,
6202
6418
  message: string().max(500).optional(),
6203
- baseVersion: number$1().int().min(0).optional()
6419
+ baseVersion: number$1().int().min(0).optional(),
6420
+ prefix: pushPrefixSchema.optional(),
6421
+ project: projectNameSchema.optional()
6204
6422
  });
6205
6423
  const versionSchema = object({
6206
6424
  number: number$1().int(),
6207
6425
  message: string().nullable(),
6208
6426
  pushedBy: userSchema,
6427
+ /** The tool the person used (agentLabel); null: the website. Missing from older servers. */
6428
+ via: string().nullable().optional(),
6429
+ /** The project it belongs to, when the client said. */
6430
+ project: string().nullable().optional(),
6209
6431
  added: number$1().int(),
6210
6432
  changed: number$1().int(),
6211
6433
  removed: number$1().int(),
@@ -6244,6 +6466,7 @@ const fileHistoryOutput = object({ entries: array(object({
6244
6466
  ]),
6245
6467
  sha256: string().nullable(),
6246
6468
  pushedBy: userSchema,
6469
+ via: string().nullable().optional(),
6247
6470
  createdAt: number$1()
6248
6471
  })) });
6249
6472
  const headOutput = object({
@@ -6531,6 +6754,11 @@ const AGENT_NAMES = {
6531
6754
  };
6532
6755
  /** "claude-code" -> "Claude Code"; unknown ids are shown as-is. */
6533
6756
  const agentLabel = (via) => AGENT_NAMES[via] ?? via;
6757
+ /**
6758
+ * Whether `via` is the person themselves rather than an agent acting for them: the website (null), the CLI typed by
6759
+ * hand ("cli"), or our own mobile app ("mobile"). Their reads count as reading, and notifications don't add "via ...".
6760
+ */
6761
+ const isPersonVia = (via) => via === null || via === "cli" || via === "mobile";
6534
6762
  const discordId = string().regex(/^\d{5,25}$/);
6535
6763
  const discordName = string().trim().min(1).max(100);
6536
6764
  const discordPersonSchema = object({
@@ -6729,6 +6957,13 @@ const discordChatOutput = object({
6729
6957
  liveUrl: string().optional()
6730
6958
  });
6731
6959
  object({ messages: array(discordMessageSchema) });
6960
+ /**
6961
+ * A person and the tool they used, for lines that say who did something: "Jack (with Claude)". The person is always
6962
+ * the subject; their own hands (the website, the CLI typed by hand, the mobile app) add nothing.
6963
+ */
6964
+ function byPerson(name, via) {
6965
+ return via && !isPersonVia(via) ? `${name} (with ${agentLabel(via)})` : name;
6966
+ }
6732
6967
  //#endregion
6733
6968
  //#region ../contract/src/sources.ts
6734
6969
  const MAX_SOURCE_BYTES = 26214400;
@@ -7216,11 +7451,33 @@ function createClient(opts) {
7216
7451
  removeMember: (slug, handle) => call("DELETE", `/v1/workspaces/${enc$3(slug)}/members/${enc$3(handle)}`, null),
7217
7452
  acceptInvite: (slug) => call("POST", `/v1/workspaces/${enc$3(slug)}/accept`, null),
7218
7453
  declineInvite: (slug) => call("POST", `/v1/workspaces/${enc$3(slug)}/decline`, null),
7219
- listSpaces: (query = {}) => call("GET", `/v1/spaces${query.workspace ? `?workspace=${enc$3(query.workspace)}` : ""}`, listSpacesOutput),
7454
+ listSpaces: (query = {}) => {
7455
+ const params = new URLSearchParams();
7456
+ if (query.workspace) params.set("workspace", query.workspace);
7457
+ if (query.status && query.status !== "active") params.set("status", query.status);
7458
+ const qs = params.toString();
7459
+ return call("GET", `/v1/spaces${qs ? `?${qs}` : ""}`, listSpacesOutput);
7460
+ },
7461
+ findSpaces: (query) => {
7462
+ const params = new URLSearchParams({ q: query.q });
7463
+ if (query.workspace) params.set("workspace", query.workspace);
7464
+ if (query.limit) params.set("limit", String(query.limit));
7465
+ return call("GET", `/v1/spaces/find?${params}`, findSpacesOutput);
7466
+ },
7220
7467
  createSpace: (input) => call("POST", "/v1/spaces", spaceSchema, input),
7221
7468
  getSpace: (id) => call("GET", space(id), spaceSchema),
7222
7469
  updateSpace: (id, input) => call("PATCH", space(id), spaceSchema, input),
7223
- deleteSpace: (id) => call("DELETE", space(id), null),
7470
+ deleteSpace: (id, opts = {}) => call("DELETE", `${space(id)}${opts.forever ? "?forever=1" : ""}`, null),
7471
+ restoreSpace: (id) => call("POST", `${space(id)}/restore`, spaceSchema),
7472
+ archiveSpace: (id) => call("POST", `${space(id)}/archive`, spaceSchema),
7473
+ unarchiveSpace: (id) => call("POST", `${space(id)}/unarchive`, spaceSchema),
7474
+ favoriteSpace: (id) => call("PUT", `${space(id)}/favorite`, null),
7475
+ fileSpace: (id, folderId) => call("PUT", `${space(id)}/folder`, spaceSchema, { folderId }),
7476
+ /** Puts this space into another as a page (Tidy up); it's archived and its links open the page there. */
7477
+ mergeSpace: (id, into) => call("POST", `${space(id)}/merge`, mergeSpaceOutput, { into }),
7478
+ unfavoriteSpace: (id) => call("DELETE", `${space(id)}/favorite`, null),
7479
+ orderFavorites: (input) => call("PUT", "/v1/me/favorites", favoritesOutput, input),
7480
+ openedSpace: (id) => call("POST", `${space(id)}/opened`, null),
7224
7481
  moveSpace: (id, input) => call("POST", `${space(id)}/move`, moveSpaceOutput, input),
7225
7482
  head: (id) => call("GET", `${space(id)}/head`, headOutput),
7226
7483
  unlockSpace: (id, input) => call("POST", `${space(id)}/unlock`, unlockSpaceOutput, input),
@@ -7326,6 +7583,22 @@ function createClient(opts) {
7326
7583
  listDiscordLinks: (spaceId) => call("GET", `/v1/spaces/${enc$3(spaceId)}/discord`, listDiscordLinksOutput),
7327
7584
  discordChat: (spaceId, threadId, all = false) => call("GET", `/v1/spaces/${enc$3(spaceId)}/discord/${enc$3(threadId)}${all ? "?all=1" : ""}`, discordChatOutput),
7328
7585
  unlinkDiscordThread: (spaceId, threadId) => send("DELETE", `/v1/spaces/${enc$3(spaceId)}/discord/${enc$3(threadId)}`),
7586
+ listOpenableSpaces: () => call("GET", "/v1/live-spaces", listOpenableSpacesOutput),
7587
+ listActivity: (slug, query = {}) => {
7588
+ const q = new URLSearchParams({ v: "2" });
7589
+ if (query.person) q.set("person", query.person);
7590
+ if (query.before) q.set("before", String(query.before));
7591
+ if (query.before && query.beforeId) q.set("beforeId", query.beforeId);
7592
+ if (query.limit) q.set("limit", String(query.limit));
7593
+ return call("GET", `/v1/workspaces/${enc$3(slug)}/activity?${q}`, listActivityOutput);
7594
+ },
7595
+ listFolders: (slug) => call("GET", `/v1/workspaces/${enc$3(slug)}/folders`, listFoldersOutput),
7596
+ /** Tidy up: your look-alike spaces that could be one, and the ones that look finished. */
7597
+ tidy: (slug) => call("GET", `/v1/workspaces/${enc$3(slug)}/tidy`, tidyOutput),
7598
+ createFolder: (slug, input) => call("POST", `/v1/workspaces/${enc$3(slug)}/folders`, folderSchema, input),
7599
+ updateFolder: (slug, id, input) => call("PATCH", `/v1/workspaces/${enc$3(slug)}/folders/${enc$3(id)}`, folderSchema, input),
7600
+ deleteFolder: (slug, id) => call("DELETE", `/v1/workspaces/${enc$3(slug)}/folders/${enc$3(id)}`, null),
7601
+ undoActivity: (slug, eventId) => call("POST", `/v1/workspaces/${enc$3(slug)}/activity/${enc$3(eventId)}/undo`, undoActivityOutput),
7329
7602
  listAudit: (slug, query = {}) => {
7330
7603
  const q = new URLSearchParams();
7331
7604
  if (query.before) q.set("before", String(query.before));
@@ -7337,6 +7610,82 @@ function createClient(opts) {
7337
7610
  };
7338
7611
  }
7339
7612
  new TextEncoder();
7613
+ new Set("a an and the of for to in on at by with from new draft copy final v1 v2 v3 page pages html index untitled my our".split(" "));
7614
+ /** A folder name for a page added to a space: "Pricing proposal D.html" → "pricing-proposal-d". */
7615
+ function pageSlug(text) {
7616
+ return text.toLowerCase().normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/\.html?$/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/-+$/, "") || "page";
7617
+ }
7618
+ //#endregion
7619
+ //#region ../contract/src/folders.ts
7620
+ const norm = (s) => s.trim().toLowerCase().replace(/\s+/g, " ");
7621
+ const parts = (path) => path.split("/").map((p) => p.trim()).filter(Boolean);
7622
+ /** True for "/" and "": the top level, outside every folder. Words like "top" are folder names (one may be called Top). */
7623
+ const isTopLevel = (path) => ["", "/"].includes(norm(path));
7624
+ /**
7625
+ * The folder a path names: its id, its whole path (any case), or a name only one folder has. Null when none does or
7626
+ * when a bare name fits several (say which with the whole path).
7627
+ */
7628
+ function findFolder(folders, path) {
7629
+ const byId = folders.find((f) => f.id === path.trim());
7630
+ if (byId) return byId;
7631
+ const want = parts(path).map(norm).join("/");
7632
+ const exact = folders.find((f) => norm(f.path) === want);
7633
+ if (exact) return exact;
7634
+ if (want.includes("/")) return null;
7635
+ const named = folders.filter((f) => norm(f.name) === want);
7636
+ return named.length === 1 ? named[0] ?? null : null;
7637
+ }
7638
+ /** Folders whose path looks like what was typed, best first: for "no folder called …, did you mean …". */
7639
+ function closeFolders(folders, path, limit = 3) {
7640
+ const words = norm(path).split(/[\s/]+/).filter(Boolean);
7641
+ return folders.map((f) => {
7642
+ const p = norm(f.path);
7643
+ return {
7644
+ f,
7645
+ score: words.filter((w) => p.includes(w)).length + (p.includes(norm(path)) ? 2 : 0)
7646
+ };
7647
+ }).filter((x) => x.score > 0).sort((a, b) => b.score - a.score || a.f.path.length - b.f.path.length).slice(0, limit).map((x) => x.f);
7648
+ }
7649
+ /** The message when a path names no folder: close matches, and how to make it. */
7650
+ function noFolderMessage(folders, path, make) {
7651
+ const near = closeFolders(folders, path);
7652
+ const named = folders.filter((f) => norm(f.name) === norm(path));
7653
+ if (named.length > 1) return `More than one folder is called "${path.trim()}": ${named.map((f) => `"${f.path}"`).join(", ")}. Give the whole path.`;
7654
+ return [
7655
+ `No folder called "${path.trim()}".`,
7656
+ near.length ? `Close: ${near.map((f) => `"${f.path}"`).join(", ")}.` : "",
7657
+ `To make it: ${make}`
7658
+ ].filter(Boolean).join(" ");
7659
+ }
7660
+ /**
7661
+ * Makes the folders a path names that don't exist yet, top down ("Deals/Enterprise" makes Deals if needed, then
7662
+ * Enterprise in it), and returns the last one, plus which were new.
7663
+ */
7664
+ async function ensureFolderPath(client, slug, path, description) {
7665
+ let folders = (await client.listFolders(slug)).folders;
7666
+ const names = parts(path);
7667
+ if (!names.length) throw new Error("Give the folder a name.");
7668
+ const made = [];
7669
+ let parent = null;
7670
+ for (const [i, name] of names.entries()) {
7671
+ const sofar = names.slice(0, i + 1).map(norm).join("/");
7672
+ let at = folders.find((f) => norm(f.path) === sofar) ?? null;
7673
+ if (!at) {
7674
+ at = await client.createFolder(slug, {
7675
+ name,
7676
+ parentId: parent?.id ?? null,
7677
+ ...i === names.length - 1 && description ? { description } : {}
7678
+ });
7679
+ made.push(at);
7680
+ folders = [...folders, at];
7681
+ }
7682
+ parent = at;
7683
+ }
7684
+ return {
7685
+ folder: parent,
7686
+ made
7687
+ };
7688
+ }
7340
7689
  //#endregion
7341
7690
  //#region ../contract/src/guest-writes.ts
7342
7691
  /**
@@ -11072,6 +11421,29 @@ function findInitRoot(from = process.cwd()) {
11072
11421
  dir = parent;
11073
11422
  }
11074
11423
  }
11424
+ /**
11425
+ * The project work belongs to, recorded on each version: CLOUDTIFACT_PROJECT, else the name of the git repo above
11426
+ * `from` (a worktree counts as its main repo), else null.
11427
+ */
11428
+ function projectName(from = process.cwd(), env = process.env) {
11429
+ const named = env.CLOUDTIFACT_PROJECT?.trim();
11430
+ if (named) return named.slice(0, 100);
11431
+ let dir = path.resolve(from);
11432
+ for (;;) {
11433
+ const git = path.join(dir, ".git");
11434
+ if (existsSync(git)) {
11435
+ try {
11436
+ const m = /^gitdir:\s*(.+)$/m.exec(readFileSync(git, "utf8"));
11437
+ const main = m?.[1] ? /^(.*)[\\/]\.git[\\/]worktrees[\\/]/.exec(m[1].trim()) : null;
11438
+ if (main?.[1]) return path.basename(main[1]);
11439
+ } catch {}
11440
+ return path.basename(dir);
11441
+ }
11442
+ const parent = path.dirname(dir);
11443
+ if (parent === dir) return null;
11444
+ dir = parent;
11445
+ }
11446
+ }
11075
11447
  function parseRepoConfig(raw, file = REPO_CONFIG_FILE) {
11076
11448
  const bad = (why) => /* @__PURE__ */ new Error(`${file}: ${why}`);
11077
11449
  if (!raw || typeof raw !== "object") throw bad("expected a JSON object");
@@ -11226,6 +11598,8 @@ function versionsData(versions) {
11226
11598
  version: v.number,
11227
11599
  at: iso(v.createdAt),
11228
11600
  by: v.pushedBy.handle,
11601
+ via: v.via ?? null,
11602
+ project: v.project ?? null,
11229
11603
  added: v.added,
11230
11604
  changed: v.changed,
11231
11605
  removed: v.removed,
@@ -11735,7 +12109,10 @@ async function pushTarget(client, target, initial, opts = {}) {
11735
12109
  const planAndUpload = async () => {
11736
12110
  const manifest = manifestOf(files);
11737
12111
  const bySha = new Map(files.map((f) => [f.sha256, f]));
11738
- const plan = await client.pushPlan(target.spaceId, { files: manifest });
12112
+ const plan = await client.pushPlan(target.spaceId, {
12113
+ files: manifest,
12114
+ ...opts.prefix ? { prefix: opts.prefix } : {}
12115
+ });
11739
12116
  if (opts.dryRun || isEmptyDiff(plan.diff)) return {
11740
12117
  plan,
11741
12118
  manifest
@@ -11767,7 +12144,9 @@ async function pushTarget(client, target, initial, opts = {}) {
11767
12144
  result = await client.createVersion(target.spaceId, {
11768
12145
  files: manifest,
11769
12146
  message: opts.message,
11770
- baseVersion: plan.baseVersion
12147
+ baseVersion: plan.baseVersion,
12148
+ ...opts.prefix ? { prefix: opts.prefix } : {},
12149
+ ...opts.project ? { project: opts.project } : {}
11771
12150
  });
11772
12151
  } catch (err) {
11773
12152
  if (!(err instanceof ApiError && err.status === 409)) throw err;
@@ -11781,7 +12160,9 @@ async function pushTarget(client, target, initial, opts = {}) {
11781
12160
  result = await client.createVersion(target.spaceId, {
11782
12161
  files: manifest,
11783
12162
  message: opts.message,
11784
- baseVersion: plan.baseVersion
12163
+ baseVersion: plan.baseVersion,
12164
+ ...opts.prefix ? { prefix: opts.prefix } : {},
12165
+ ...opts.project ? { project: opts.project } : {}
11785
12166
  });
11786
12167
  }
11787
12168
  if (!result.version) return {
@@ -11820,6 +12201,7 @@ async function pushRepo(client, repo, opts = {}) {
11820
12201
  });
11821
12202
  const r = await pushTarget(client, target, files, {
11822
12203
  ...opts,
12204
+ project: opts.project ?? projectName(repo.root),
11823
12205
  recollect: async () => (await collect(repo.docsDir)).files
11824
12206
  });
11825
12207
  results.push(r);
@@ -11860,9 +12242,10 @@ function commentsUrl(shareUrl) {
11860
12242
  * app, with a Comment button in the corner) and with the comment panel already open. A private page has only the
11861
12243
  * app link, which members open inside the Cloudtifact viewer.
11862
12244
  */
11863
- function linkLines(space) {
11864
- if (space.shareUrl) return [`Page: ${space.shareUrl}`, `With comments: ${commentsUrl(space.shareUrl)}`];
11865
- return [`Link: ${space.url}`, `Private: only members of the ${space.workspace.slug} workspace can open it. To make it public, change visibility in the space settings on the website.`];
12245
+ function linkLines(space, prefix) {
12246
+ const share = space.shareUrl && prefix ? space.shareUrl + prefix : space.shareUrl;
12247
+ if (share) return [`Page: ${share}`, `With comments: ${commentsUrl(share)}`];
12248
+ return [`Link: ${prefix ? `${space.url}/${prefix}index.html` : space.url}`, `Private: only members of the ${space.workspace.slug} workspace can open it. To make it public, change visibility in the space settings on the website.`];
11866
12249
  }
11867
12250
  /** A local, relative reference, or null for absolute URLs, data:, anchors, etc. Drops ?query and #hash. */
11868
12251
  function localRef(raw) {
@@ -11986,7 +12369,44 @@ async function publishToSpace(client, spaceId, bundle, opts) {
11986
12369
  });
11987
12370
  await applyGuestWrites(client, spaceId, opts.folder, { log: opts.log });
11988
12371
  }
11989
- return pushTarget(client, { spaceId }, bundle.files, { message: opts.message });
12372
+ const prefix = opts.prefix;
12373
+ const files = prefix ? bundle.files.map((f) => ({
12374
+ ...f,
12375
+ path: prefix + f.path
12376
+ })) : bundle.files;
12377
+ return pushTarget(client, { spaceId }, files, {
12378
+ message: opts.message,
12379
+ prefix,
12380
+ project: opts.project
12381
+ });
12382
+ }
12383
+ /**
12384
+ * The space new work belongs in: an active space in the workspace you can publish to, whose name matches the page
12385
+ * strongly (topicMatch), with the same who-can-see as asked for. A private page never lands in a public space, and a
12386
+ * public one never in a private space: publishing doesn't change who can see anything. Null: make a new space.
12387
+ */
12388
+ async function findHome(client, title, opts) {
12389
+ return (await client.findSpaces({
12390
+ q: title,
12391
+ workspace: opts.workspace,
12392
+ limit: 10
12393
+ }).catch((e) => {
12394
+ if (e instanceof ApiError && (e.status === 404 || e.status === 400)) return { spaces: [] };
12395
+ throw e;
12396
+ })).spaces.find((s) => s.strong && s.kind === "repo" && s.permissions.canPush && !s.passwordProtected && s.visibility === "public" === opts.public) ?? null;
12397
+ }
12398
+ /** A folder for the page in the space that no other page uses: "pricing-d/", else "pricing-d-2/", ... */
12399
+ async function freePrefix(client, space, name) {
12400
+ const taken = /* @__PURE__ */ new Set();
12401
+ if (space.latestVersion > 0) {
12402
+ const latest = await client.getVersion(space.id, "latest");
12403
+ for (const f of latest.files) taken.add(f.path.split("/")[0] ?? "");
12404
+ }
12405
+ const base = pageSlug(name);
12406
+ for (let i = 1;; i++) {
12407
+ const slug = i === 1 ? base : `${base}-${i}`;
12408
+ if (!taken.has(slug) && !taken.has(`${slug}.html`)) return `${slug}/`;
12409
+ }
11990
12410
  }
11991
12411
  const artifactsPath = (env = process.env) => path.join(globalConfigDir(env), "artifacts.json");
11992
12412
  async function readArtifacts(env = process.env) {
@@ -12062,7 +12482,7 @@ function tokenSpace(token) {
12062
12482
  //#endregion
12063
12483
  //#region src/version.ts
12064
12484
  /** This CLI's version, from package.json at build time (tsdown.config.ts). Unbuilt (tests, tsx): "0.0.0-dev". */
12065
- const VERSION = "0.2.3";
12485
+ const VERSION = "0.2.4";
12066
12486
  /**
12067
12487
  * Sent on every API request, so the API can tell which CLI version is calling (and one day tell an old one to
12068
12488
  * upgrade). Without it Node sends just "node", which is what 0.2.2 and older send. The sign-in page shows it too.
@@ -12343,6 +12763,25 @@ function chatsToMarkdown(spaceName, chats) {
12343
12763
  return `${out.join("\n").trimEnd()}\n`;
12344
12764
  }
12345
12765
  //#endregion
12766
+ //#region src/folders.ts
12767
+ /** The folder a path names, or null for "/" (the top level). Unknown paths fail with close matches and the fix. */
12768
+ async function folderFor(client, slug, path) {
12769
+ if (isTopLevel(path)) return null;
12770
+ const { folders } = await client.listFolders(slug);
12771
+ const f = findFolder(folders, path);
12772
+ if (!f) throw new CliError(noFolderMessage(folders, path, `cloudtifact folder create "${path.trim()}"`));
12773
+ return f;
12774
+ }
12775
+ /** "Product plans › Pricing": a path as people read it. */
12776
+ const shownPath = (f) => f ? f.path.replaceAll("/", " › ") : "the top level";
12777
+ /** The tree as indented lines: name, how many spaces, what it's for. */
12778
+ function treeLines(folders) {
12779
+ return folders.map((f) => {
12780
+ const depth = f.path.split("/").length - 1;
12781
+ return `${" ".repeat(depth)}▤ ${f.name} ${f.spaces}${f.description ? ` · ${f.description}` : ""}`;
12782
+ });
12783
+ }
12784
+ //#endregion
12346
12785
  //#region src/hooks.ts
12347
12786
  const SCRIPT_INSTALL_BIN = "\"$HOME/.cloudtifact/bin/cloudtifact\"";
12348
12787
  /** The command every harness runs after a file edit. Reads the hook payload on stdin, always exits 0. */
@@ -12779,6 +13218,7 @@ When you make an HTML page for the user, run \`cloudtifact publish <file.html>\`
12779
13218
  - To update it, edit the same file and publish it again: new version, same link.
12780
13219
  - Keep supporting files next to the HTML (or in subfolders) and reference them with relative paths (\`style.css\`, \`img/logo.png\`). Absolute paths and files outside the page's folder aren't included.
12781
13220
  - Pages are private by default; add \`--public\` on the first publish for a link anyone can open. \`cloudtifact artifacts\` lists what you've published.
13221
+ - New work joins the space already about the same topic, as a page, instead of making another space: \`publish\` finds it (same workspace, same who-can-see) and prints \`Looks like "<space>"\`. Tell the user where it went. To check first, run \`cloudtifact find "<title>" --toon\`; \`--add-to <id>\` adds the page to a space you pick, \`--new\` makes a separate space when the user wants one.
12782
13222
 
12783
13223
  ## Space files
12784
13224
 
@@ -12788,6 +13228,18 @@ People can add files to a space for the work (chat exports, emails, PDFs, spread
12788
13228
 
12789
13229
  Spaces live in workspaces (personal or team; the user can belong to several). \`cloudtifact publish\` and \`cloudtifact init\` create new spaces in the current workspace and print which one; pass \`--workspace <slug>\` to pick another. \`cloudtifact workspace list\` shows the user's workspaces (current one marked); \`cloudtifact workspace use <slug>\` switches the default. Pushing to an existing space doesn't need a workspace. Only change the default when the user asks.
12790
13230
 
13231
+ ## Folders
13232
+
13233
+ A workspace's spaces can sit in folders (any depth, like "Deals/Enterprise"); everyone in it shares them. \`cloudtifact folders --toon\` lists every path, how many spaces it holds and what goes there: read the descriptions before filing.
13234
+
13235
+ - File a new space with \`publish --folder "<path>"\` when the user named a folder, \`.cloudtifact.json\` says one, or a folder's description clearly says it's where this goes. Otherwise leave it out and say where it went. An unknown folder fails with close matches: don't make one to get past it.
13236
+ - When the user asks, you can make, rename, describe and move folders and move spaces: \`cloudtifact folder create "<path>" [--description "<what goes here>"]\` (makes the folders the path needs), \`folder rename <path> <name>\`, \`folder describe <path> "<text>"\`, \`folder move <path> <to-path or />\`, and \`cloudtifact move <space> <folder-path or />\`. Never on your own: suggest it and let the user decide.
13237
+ - Every change is in the workspace's Activity under the user's name, with Undo (\`cloudtifact activity\`, \`cloudtifact undo <id>\`). Moving never changes a space's link or who can open it. Only the person can remove a folder, in the Cloudtifact app.
13238
+
13239
+ ## Putting spaces away
13240
+
13241
+ When the user asks to archive, clear out or tidy up spaces, run \`cloudtifact archive <id>\`: the space leaves every list while its link, comments and data keep working, and \`cloudtifact unarchive <id>\` (or publishing to it again) brings it back. Never try to delete a space: only the person can, in the Cloudtifact app. \`cloudtifact favorite <id>\` pins a space at the top of the user's own sidebar.
13242
+
12791
13243
  ## Space databases
12792
13244
 
12793
13245
  Every space has its own SQLite database, shared by all its pages. Commands take \`--space <id>\` (default: the first target in \`.cloudtifact.json\`) and \`--toon\` / \`--json\`:
@@ -13212,7 +13664,7 @@ const program = new Command().name("cloudtifact").description("Publish pages and
13212
13664
  const tty = !!process.stdout.isTTY && !!process.stderr.isTTY;
13213
13665
  if (updateNoticeOff({
13214
13666
  env: process.env,
13215
- current: "0.2.3",
13667
+ current: "0.2.4",
13216
13668
  format,
13217
13669
  command: cmd.name(),
13218
13670
  tty
@@ -13500,7 +13952,7 @@ program.command("init").description("Link this repo to a space (creating one in
13500
13952
  console.log("Next: `cloudtifact push`");
13501
13953
  if (opts.hooks) await hooksInstall(root, {});
13502
13954
  }));
13503
- program.command("push").description("Upload changed files and record a new version on every target").option("-m, --message <message>", "version message").option("--dry-run", "show what would change, upload nothing").option("-q, --quiet", "print nothing unless something fails").option("--no-db", "don't run the docs folder's db/*.sql files").addOption(new Option("--if-changed-path <file>", "only push if this file is inside the docs folder").hideHelp()).action(run(async (opts) => {
13955
+ program.command("push").description("Upload changed files and record a new version on every target").option("-m, --message <message>", "version message").option("--dry-run", "show what would change, upload nothing").option("-q, --quiet", "print nothing unless something fails").option("--no-db", "don't run the docs folder's db/*.sql files").option("--project <name>", "the project this work belongs to (default: the git repo's name)").addOption(new Option("--if-changed-path <file>", "only push if this file is inside the docs folder").hideHelp()).action(run(async (opts) => {
13504
13956
  const repo = await loadRepo();
13505
13957
  if (opts.ifChangedPath && !isInside(repo.docsDir, opts.ifChangedPath)) return;
13506
13958
  const { client } = await getClient();
@@ -13508,6 +13960,7 @@ program.command("push").description("Upload changed files and record a new versi
13508
13960
  message: opts.message,
13509
13961
  dryRun: opts.dryRun,
13510
13962
  db: opts.db,
13963
+ project: opts.project,
13511
13964
  log: opts.quiet ? void 0 : (l) => console.log(l)
13512
13965
  })).some((r) => r.status === "error")) process.exitCode = 1;
13513
13966
  }));
@@ -13722,7 +14175,9 @@ for (const [name, verb] of [["resolve", "Resolved"], ["unresolve", "Reopened"]])
13722
14175
  const c = await (name === "resolve" ? client.resolveComment(commentId) : client.unresolveComment(commentId));
13723
14176
  console.log(`${verb} ${c.id} on ${c.path}.`);
13724
14177
  }));
13725
- program.command("publish").description("Publish an HTML page (with the files it references) or a folder; prints its link").argument("<path>", "an .html file or a folder with index.html").option("--public", "anyone with the link can view (only when first published; default: private)").option("--name <name>", "space name (default: the page's <title>, else the file name)").option("--workspace <slug>", "workspace for a new space (default: the current one, see `workspace list`)").option("--space <id>", "publish into this existing space (id or link) instead of making one").option("--entry <file>", "folder only: the page to publish as index.html").option("-m, --message <message>", "version message").option("--no-db", "don't run the db/*.sql files next to the page").action(run(async (target, opts) => {
14178
+ program.command("publish").description("Publish an HTML page (with the files it references) or a folder; prints its link").argument("<path>", "an .html file or a folder with index.html").option("--public", "anyone with the link can view (only when first published; default: private)").option("--name <name>", "space name (default: the page's <title>, else the file name)").option("--workspace <slug>", "workspace for a new space (default: the current one, see `workspace list`)").option("--space <id>", "publish as this existing space's main page (id or link), replacing its files").option("--add-to <id>", "add it as a page in this existing space (id or link), keeping the space's other pages").option("--new", "make a new space, even when an existing one looks like the same topic").option("--folder <path>", "file a new space in this folder, like \"Deals/Enterprise\" (see `cloudtifact folders`)").option("--project <name>", "the project this work belongs to (default: the git repo it's in)").option("--entry <file>", "folder only: the page to publish as index.html").option("-m, --message <message>", "version message").option("--no-db", "don't run the db/*.sql files next to the page").action(run(async (target, opts) => {
14179
+ if (opts.space && opts.addTo) throw new CliError("Pass --space or --add-to, not both.");
14180
+ if ((opts.space || opts.addTo) && opts.new) throw new CliError("--new makes a new space; drop --space/--add-to.");
13726
14181
  const abs = path.resolve(target);
13727
14182
  if (!existsSync(abs)) throw new CliError(`${target} does not exist.`);
13728
14183
  const isDir = statSync(abs).isDirectory();
@@ -13733,32 +14188,78 @@ program.command("publish").description("Publish an HTML page (with the files it
13733
14188
  const { client, baseUrl, config, account } = await getClient();
13734
14189
  const saved = lookupArtifact(await readArtifacts(), abs, baseUrl);
13735
14190
  let space = null;
13736
- if (opts.space) {
14191
+ let prefix;
14192
+ if (opts.addTo) {
14193
+ space = await client.getSpace(spaceIdOrThrow(opts.addTo));
14194
+ prefix = saved?.spaceId === space.id && saved.prefix ? saved.prefix : await freePrefix(client, space, path.basename(abs));
14195
+ if (saved?.spaceId !== space.id || saved.prefix !== prefix) await saveArtifact(abs, {
14196
+ spaceId: space.id,
14197
+ apiUrl: baseUrl,
14198
+ createdAt: Date.now(),
14199
+ prefix,
14200
+ ...account ? { handle: account.handle } : {}
14201
+ });
14202
+ } else if (opts.space) {
13737
14203
  space = await client.getSpace(spaceIdOrThrow(opts.space));
13738
14204
  if (saved?.spaceId !== space.id) await saveArtifact(abs, {
13739
14205
  spaceId: space.id,
13740
14206
  apiUrl: baseUrl,
13741
14207
  createdAt: Date.now()
13742
14208
  });
13743
- } else if (saved) {
14209
+ } else if (saved && !opts.new) {
13744
14210
  space = await client.getSpace(saved.spaceId).catch((e) => {
13745
14211
  if (e instanceof ApiError && e.status === 404) return null;
13746
14212
  throw e;
13747
14213
  });
13748
14214
  if (!space && saved.handle && account && saved.handle !== account.handle) throw new CliError(`This page was published as @${saved.handle} (space ${saved.spaceId}); you don't have access as @${account.handle}. Run it with --account ${saved.handle}.`);
14215
+ if (space) prefix = saved.prefix;
13749
14216
  if (!space) console.error(`Space ${saved.spaceId} is gone; creating a new one.`);
13750
14217
  else if (opts.workspace && opts.workspace !== space.workspace.slug) console.error(`note: this page is already published in ${space.workspace.slug}; --workspace only applies to new spaces.`);
13751
14218
  else if (opts.public && !space.shareUrl) console.error("note: --public only applies when first published; this artifact stays private.");
13752
14219
  }
14220
+ let newIn;
14221
+ const newSpaceWorkspace = async () => newIn ??= await workspaceForNewSpace(client, opts.workspace, config.workspace);
13753
14222
  if (!space) {
13754
14223
  const name = opts.name ?? bundle.title ?? path.basename(abs);
13755
- const cur = await workspaceForNewSpace(client, opts.workspace, config.workspace);
14224
+ const cur = await newSpaceWorkspace();
13756
14225
  console.error(workspaceLine(cur));
14226
+ const home = opts.new ? null : await findHome(client, name, {
14227
+ workspace: cur.workspace.slug,
14228
+ public: !!opts.public
14229
+ });
14230
+ if (home) {
14231
+ space = await client.getSpace(home.id);
14232
+ prefix = await freePrefix(client, space, path.basename(abs));
14233
+ console.error(`Looks like "${space.name}" (${space.id}): adding it there as a page. Use --new for a separate space.`);
14234
+ await saveArtifact(abs, {
14235
+ spaceId: space.id,
14236
+ apiUrl: baseUrl,
14237
+ createdAt: Date.now(),
14238
+ prefix,
14239
+ ...account ? { handle: account.handle } : {}
14240
+ });
14241
+ }
14242
+ }
14243
+ let filed;
14244
+ if (opts.folder) {
14245
+ if (space && prefix !== void 0) console.error(`note: it's a page in "${space.name}", so it stays where that space is; --folder was not used.`);
14246
+ else if (space) console.error(`note: "${space.name}" is already published; --folder only files a new space. To move it: cloudtifact move ${space.id} "${opts.folder}"`);
14247
+ else filed = await folderFor(client, (await newSpaceWorkspace()).workspace.slug, opts.folder).catch((e) => {
14248
+ if (!(e instanceof CliError)) throw e;
14249
+ console.error(`warning: ${e.message}; publishing it at the top level instead.`);
14250
+ return null;
14251
+ });
14252
+ }
14253
+ if (!space) {
14254
+ const name = opts.name ?? bundle.title ?? path.basename(abs);
14255
+ const cur = await newSpaceWorkspace();
13757
14256
  space = await client.createSpace({
13758
14257
  workspace: cur.workspace.slug,
13759
14258
  name,
13760
- visibility: opts.public ? "public" : "members"
14259
+ visibility: opts.public ? "public" : "members",
14260
+ ...filed ? { folderId: filed.id } : {}
13761
14261
  });
14262
+ if (filed) console.error(`Filed in ${shownPath(filed)}`);
13762
14263
  await saveArtifact(abs, {
13763
14264
  spaceId: space.id,
13764
14265
  apiUrl: baseUrl,
@@ -13768,6 +14269,8 @@ program.command("publish").description("Publish an HTML page (with the files it
13768
14269
  }
13769
14270
  const r = await publishToSpace(client, space.id, bundle, {
13770
14271
  folder: pageFolder(abs, isDir),
14272
+ prefix,
14273
+ project: opts.project ?? projectName(path.dirname(abs)),
13771
14274
  message: opts.message,
13772
14275
  db: opts.db,
13773
14276
  log: (l) => console.log(l),
@@ -13776,7 +14279,31 @@ program.command("publish").description("Publish an HTML page (with the files it
13776
14279
  const n = bundle.files.length;
13777
14280
  if (r.status === "pushed") console.log(`Published "${space.name}" v${r.version} (${n} file${n === 1 ? "" : "s"}; ${summarize(r.diff)})`);
13778
14281
  else console.log(`No changes since v${space.latestVersion}: "${space.name}"`);
13779
- for (const line of linkLines(space)) console.log(line);
14282
+ for (const line of linkLines(space, prefix)) console.log(line);
14283
+ }));
14284
+ program.command("find").description("Find the spaces a topic already lives in, best match first, so new work can go in as a page").argument("<words...>", "what it's about, like a title").option("--workspace <slug>", "only this workspace (default: all of yours)").option("--toon", "print TOON (compact, for agents)").option("--json", "print JSON").action(run(async (words, opts) => {
14285
+ const { client } = await getClient();
14286
+ const { spaces } = await client.findSpaces({
14287
+ q: words.join(" "),
14288
+ workspace: opts.workspace,
14289
+ limit: 10
14290
+ });
14291
+ const format = resolveFormat(opts);
14292
+ const rows = spaces.map((s) => ({
14293
+ id: s.id,
14294
+ name: s.name,
14295
+ workspace: s.workspace.slug,
14296
+ pages: s.pages,
14297
+ match: s.strong ? "strong" : "weak",
14298
+ page: s.page,
14299
+ visibility: s.visibility,
14300
+ canPublish: s.permissions.canPush,
14301
+ link: s.shareUrl ?? s.url
14302
+ }));
14303
+ if (format !== "text") return printData(format, { spaces: rows });
14304
+ if (!rows.length) return console.log("Nothing matches. `cloudtifact publish` will make a new space.");
14305
+ for (const r of rows) console.log(`${r.id} ${r.name} ${r.pages} page${r.pages === 1 ? "" : "s"} · ${r.workspace}${r.match === "strong" ? " · same topic" : ""}${r.page ? ` · page "${r.page}"` : ""}`);
14306
+ console.log("Add a page to one: cloudtifact publish <file> --add-to <id>");
13780
14307
  }));
13781
14308
  program.command("artifacts").description("List pages published with `cloudtifact publish`").option("--toon", "print TOON (compact, for agents)").option("--json", "print JSON").action(run(async (opts) => {
13782
14309
  const map = await readArtifacts();
@@ -13816,7 +14343,7 @@ program.command("versions").description("List a space's versions, newest first")
13816
14343
  versions: rows
13817
14344
  });
13818
14345
  if (!rows.length) return console.log("Nothing pushed yet.");
13819
- for (const v of rows) console.log(`v${v.version} ${v.at} UTC @${v.by} +${v.added} ~${v.changed} -${v.removed}${v.message ? ` ${v.message}` : ""}`);
14346
+ for (const v of rows) console.log(`v${v.version} ${v.at} UTC ${byPerson(`@${v.by}`, v.via)}${v.project ? ` · ${v.project}` : ""} +${v.added} ~${v.changed} -${v.removed}${v.message ? ` ${v.message}` : ""}`);
13820
14347
  }));
13821
14348
  program.command("settings").description("Show a space's guest and visitor switches, or flip them (space admins; the same switches as in the app)").option("--space <id>", "space id or link (default: the first target in .cloudtifact.json)").option(SETTINGS[0].option, SETTINGS[0].label).option(SETTINGS[1].option, `${SETTINGS[1].label} (off keeps the database private from visitors)`).option(SETTINGS[2].option, SETTINGS[2].label).option("--toon", "print TOON (compact, for agents)").option("--json", "print JSON").action(run(async (opts) => {
13822
14349
  const { spaceId } = await pickSpace(opts.space);
@@ -13827,6 +14354,187 @@ program.command("settings").description("Show a space's guest and visitor switch
13827
14354
  if (format !== "text") return printData(format, settingsData(space));
13828
14355
  process.stdout.write(settingsText(space));
13829
14356
  }));
14357
+ for (const [name, on] of [["archive", true], ["unarchive", false]]) program.command(name).description(on ? "Archive a space: it leaves every list, its link, comments and data keep working, and a new version brings it back" : "Bring an archived space back into the lists").argument("[space]", "space id or link (default: the first target in .cloudtifact.json)").option("--space <id>", "space id or link (same as the argument)").option("--json", "print JSON").action(run(async (arg, opts) => {
14358
+ const { spaceId } = await pickSpace(arg ?? opts.space);
14359
+ const { client } = await getClient();
14360
+ const space = on ? await client.archiveSpace(spaceId) : await client.unarchiveSpace(spaceId);
14361
+ if (opts.json) return printData("json", {
14362
+ id: space.id,
14363
+ name: space.name,
14364
+ archived: on,
14365
+ link: space.url
14366
+ });
14367
+ console.log(on ? `Archived "${space.name}". It's out of the lists; the link still works: ${space.url}\nUndo: cloudtifact unarchive ${space.id}` : `"${space.name}" is back in the lists: ${space.url}`);
14368
+ }));
14369
+ program.command("spaces").description("List spaces: the ones you made, the ones shared with you, or the ones waiting on you").option("--mine", "only the ones you made (Made by me)").option("--shared", "only the ones people in other workspaces shared with you (Shared with me)").option("--waiting", "comment threads waiting on you, by space (Waiting on me)").option("--workspace <slug>", "which workspace (default: your default one)").option("--folder <path>", "only the spaces in this folder (\"/\" for the ones in no folder)").option("--toon", "print TOON (compact, for agents)").option("--json", "print JSON").action(run(async (opts) => {
14370
+ const { client, config } = await getClient();
14371
+ const me = await client.me();
14372
+ const format = resolveFormat(opts);
14373
+ const mineSlugs = new Set(me.workspaces.map((w) => w.slug));
14374
+ if (opts.shared) {
14375
+ const rows = (await client.listOpenableSpaces()).liveSpaces.filter((s) => !mineSlugs.has(s.workspace.slug)).map((s) => ({
14376
+ id: s.id,
14377
+ name: s.name,
14378
+ workspace: s.workspace.slug,
14379
+ link: s.url
14380
+ }));
14381
+ if (format !== "text") return printData(format, { spaces: rows });
14382
+ if (!rows.length) return console.log("Nobody has shared a space with you from another workspace.");
14383
+ for (const r of rows) console.log(`${r.id} ${r.name} · ${r.workspace}`);
14384
+ return;
14385
+ }
14386
+ const slug = requireWorkspace(me.workspaces, {
14387
+ flag: opts.workspace,
14388
+ saved: config.workspace
14389
+ }).workspace.slug;
14390
+ if (opts.waiting) {
14391
+ const w = await client.waiting(slug, { limit: 50 });
14392
+ const rows = w.threads.map((t) => ({
14393
+ space: t.space.id,
14394
+ name: t.space.name,
14395
+ thread: t.thread.id,
14396
+ why: t.mentioned ? "mentioned you" : t.reason === "owner" ? "on a space you started" : "you wrote in it",
14397
+ at: iso(t.lastAt)
14398
+ }));
14399
+ if (format !== "text") return printData(format, {
14400
+ workspace: slug,
14401
+ total: w.total,
14402
+ threads: rows
14403
+ });
14404
+ if (!rows.length) return console.log("Nothing is waiting on you.");
14405
+ for (const r of rows) console.log(`${r.space} ${r.name} · ${r.why} · ${r.at} UTC`);
14406
+ console.log(`Read one: cloudtifact comments --space <id>`);
14407
+ return;
14408
+ }
14409
+ const [{ spaces }, { folders }] = await Promise.all([client.listSpaces({ workspace: slug }), client.listFolders(slug).catch(() => ({ folders: [] }))]);
14410
+ const only = opts.folder === void 0 ? void 0 : (await folderFor(client, slug, opts.folder))?.id ?? null;
14411
+ const pathOf = new Map(folders.map((f) => [f.id, f.path]));
14412
+ const rows = spaces.filter((s) => s.kind !== "app" && (!opts.mine || s.createdBy === me.user.id)).filter((s) => only === void 0 || (s.folderId ?? null) === only).map((s) => ({
14413
+ id: s.id,
14414
+ name: s.name,
14415
+ folder: s.folderId ? pathOf.get(s.folderId) ?? null : null,
14416
+ project: s.project ?? null,
14417
+ visibility: s.visibility,
14418
+ link: s.shareUrl ?? s.url
14419
+ }));
14420
+ if (format !== "text") return printData(format, {
14421
+ workspace: slug,
14422
+ spaces: rows
14423
+ });
14424
+ if (!rows.length) return console.log(opts.mine ? "You haven't made a space here yet." : "No spaces yet.");
14425
+ for (const r of rows) console.log(`${r.id} ${r.name}${r.folder ? ` ▤ ${r.folder}` : ""}${r.project ? ` · ${r.project}` : ""}`);
14426
+ }));
14427
+ program.command("activity").description("What people did to the spaces of a workspace, newest first, with the id to undo each").option("--workspace <slug>", "which workspace (default: your default one)").option("--person <handle>", "only what this person did").option("-n, --limit <n>", "how many to show", "20").option("--toon", "print TOON (compact, for agents)").option("--json", "print JSON").action(run(async (opts) => {
14428
+ const { client, config } = await getClient();
14429
+ const cur = requireWorkspace((await client.me()).workspaces, {
14430
+ flag: opts.workspace,
14431
+ saved: config.workspace
14432
+ });
14433
+ const { events } = await client.listActivity(cur.workspace.slug, {
14434
+ person: opts.person?.replace(/^@/, ""),
14435
+ limit: Math.min(100, Math.max(1, Number(opts.limit) || 20))
14436
+ });
14437
+ const rows = events.map((e) => ({
14438
+ id: e.id,
14439
+ at: iso(e.createdAt),
14440
+ what: `${byPerson(e.actor ? e.actor.name ?? `@${e.actor.handle}` : "A deleted account", e.via)} ${describeActivity(e)}`,
14441
+ space: e.space?.id ?? null,
14442
+ folder: e.folder?.id ?? null,
14443
+ canUndo: e.canUndo,
14444
+ undone: e.undone
14445
+ }));
14446
+ const format = resolveFormat(opts);
14447
+ if (format !== "text") return printData(format, {
14448
+ workspace: cur.workspace.slug,
14449
+ events: rows
14450
+ });
14451
+ if (!rows.length) return console.log("Nothing yet.");
14452
+ for (const r of rows) console.log(`${r.id} ${r.at} UTC ${r.what}${r.undone ? " (undone)" : r.canUndo ? "" : " (can't undo)"}`);
14453
+ if (rows.some((r) => r.canUndo)) console.log("Undo one: cloudtifact undo <id>");
14454
+ }));
14455
+ program.command("undo").description("Undo a change from `cloudtifact activity`: brings back, restores, or takes out an added page").argument("<id>", "the change's id, from `cloudtifact activity`").option("--workspace <slug>", "which workspace (default: your default one)").action(run(async (id, opts) => {
14456
+ const { client, config } = await getClient();
14457
+ const cur = requireWorkspace((await client.me()).workspaces, {
14458
+ flag: opts.workspace,
14459
+ saved: config.workspace
14460
+ });
14461
+ const { event } = await client.undoActivity(cur.workspace.slug, id);
14462
+ console.log(`Undone: ${byPerson(event.actor ? event.actor.name ?? `@${event.actor.handle}` : "You", event.via)} ${describeActivity(event)}`);
14463
+ }));
14464
+ /** The workspace a folder command works in. */
14465
+ async function folderWorkspace(opts) {
14466
+ const { client, config } = await getClient();
14467
+ return {
14468
+ client,
14469
+ slug: requireWorkspace((await client.me()).workspaces, {
14470
+ flag: opts.workspace,
14471
+ saved: config.workspace
14472
+ }).workspace.slug
14473
+ };
14474
+ }
14475
+ program.command("folders").description("The workspace's folders: every path, how many spaces, and what each is for (read it before filing)").option("--workspace <slug>", "which workspace (default: your default one)").option("--toon", "print TOON (compact, for agents)").option("--json", "print JSON").action(run(async (opts) => {
14476
+ const { client, slug } = await folderWorkspace(opts);
14477
+ const { folders } = await client.listFolders(slug);
14478
+ const format = resolveFormat(opts);
14479
+ if (format !== "text") return printData(format, {
14480
+ workspace: slug,
14481
+ folders: folders.map((f) => ({
14482
+ id: f.id,
14483
+ path: f.path,
14484
+ spaces: f.spaces,
14485
+ description: f.description
14486
+ }))
14487
+ });
14488
+ if (!folders.length) return console.log("No folders yet. Make one: cloudtifact folder create \"Product plans\"");
14489
+ for (const l of treeLines(folders)) console.log(l);
14490
+ }));
14491
+ const folder = program.command("folder").description("Make, rename, describe or move folders (each change is in Activity, with Undo)");
14492
+ folder.command("create").description("Make a folder; a path like \"Deals/Enterprise\" makes the folders it needs").argument("<path>", "the folder's path").option("--description <text>", "what goes in it (people and agents read this before filing)").option("--workspace <slug>", "which workspace (default: your default one)").action(run(async (p, opts) => {
14493
+ const { client, slug } = await folderWorkspace(opts);
14494
+ const { folder: f, made } = await ensureFolderPath(client, slug, p, opts.description);
14495
+ if (!made.length) {
14496
+ console.log(`"${f.path}" is already there (${f.id}).`);
14497
+ if (opts.description) console.error(`note: its description wasn't changed. To change it: cloudtifact folder describe "${f.path}" "…"`);
14498
+ return;
14499
+ }
14500
+ console.log(`Made ${made.map((m) => `"${m.path}"`).join(", ")} (${f.id}).`);
14501
+ }));
14502
+ folder.command("rename").description("Rename a folder").argument("<path>", "the folder's path or id").argument("<name>", "its new name").option("--workspace <slug>", "which workspace (default: your default one)").action(run(async (p, name, opts) => {
14503
+ const { client, slug } = await folderWorkspace(opts);
14504
+ const f = await folderFor(client, slug, p);
14505
+ if (!f) throw new CliError("The top level can't be renamed.");
14506
+ const next = await client.updateFolder(slug, f.id, { name });
14507
+ console.log(`Renamed "${f.path}" to "${next.path}".`);
14508
+ }));
14509
+ folder.command("describe").description("Say what goes in a folder (people and agents read it before filing); empty text clears it").argument("<path>", "the folder's path or id").argument("<text>", "one line").option("--workspace <slug>", "which workspace (default: your default one)").action(run(async (p, text, opts) => {
14510
+ const { client, slug } = await folderWorkspace(opts);
14511
+ const f = await folderFor(client, slug, p);
14512
+ if (!f) throw new CliError("The top level has no description.");
14513
+ await client.updateFolder(slug, f.id, { description: text.trim() || null });
14514
+ console.log(text.trim() ? `Described "${f.path}".` : `Cleared the description of "${f.path}".`);
14515
+ }));
14516
+ folder.command("move").description("Move a folder (and everything in it) into another one, or to the top level with \"/\"").argument("<path>", "the folder's path or id").argument("<to>", "where: a folder path, or \"/\" for the top level").option("--workspace <slug>", "which workspace (default: your default one)").action(run(async (p, to, opts) => {
14517
+ const { client, slug } = await folderWorkspace(opts);
14518
+ const f = await folderFor(client, slug, p);
14519
+ if (!f) throw new CliError("The top level can't move.");
14520
+ const into = await folderFor(client, slug, to);
14521
+ const next = await client.updateFolder(slug, f.id, { parentId: into?.id ?? null });
14522
+ console.log(`Moved "${f.name}" to ${shownPath(into)}: now "${next.path}".`);
14523
+ }));
14524
+ program.command("move").description("Move a space into a folder, or to the top level with \"/\"; its link and who can open it stay the same").argument("<space>", "space id or link").argument("<folder>", "a folder path like \"Product plans/Pricing\", or \"/\"").action(run(async (ref, to) => {
14525
+ const { client } = await getClient();
14526
+ const space = await client.getSpace(spaceIdOrThrow(ref));
14527
+ const into = await folderFor(client, space.workspace.slug, to);
14528
+ await client.fileSpace(space.id, into?.id ?? null);
14529
+ console.log(`Moved "${space.name}" to ${shownPath(into)}. Undo: cloudtifact activity`);
14530
+ }));
14531
+ program.command("favorite").description("Add a space to your favorites, at the top of your sidebar (only you see them)").argument("[space]", "space id or link (default: the first target in .cloudtifact.json)").option("--space <id>", "space id or link (same as the argument)").option("--remove", "take it off your favorites").action(run(async (arg, opts) => {
14532
+ const { spaceId } = await pickSpace(arg ?? opts.space);
14533
+ const { client } = await getClient();
14534
+ if (opts.remove) await client.unfavoriteSpace(spaceId);
14535
+ else await client.favoriteSpace(spaceId);
14536
+ console.log(opts.remove ? `Removed ${spaceId} from your favorites.` : `Added ${spaceId} to your favorites.`);
14537
+ }));
13830
14538
  program.command("upload").description("Upload files of any type into a space (up to 100 MB each); download them anywhere you sign in").argument("<files...>").option("--space <id>", "space id or link (default: the first target in .cloudtifact.json)").action(run(async (files, opts) => {
13831
14539
  const { spaceId } = await pickSpace(opts.space, { noRepo: "Pass --space <id>, or run inside a linked repo." });
13832
14540
  const { client } = await getClient();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudtifact",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Publish pages and docs to Cloudtifact as links people comment on, with every version kept and the comments handed back to your agent.",
5
5
  "license": "MIT",
6
6
  "author": "The Cloudtifact authors",