@coderook/cli 0.31.12 → 0.31.13

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.
@@ -2,7 +2,7 @@
2
2
  "name": "coderook",
3
3
  "displayName": "CodeRook",
4
4
  "description": "Save, browse and restore whole-snapshot versions of a project on CodeRook, from Claude Code.",
5
- "version": "0.31.12",
5
+ "version": "0.31.13",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
package/README.md CHANGED
@@ -162,6 +162,36 @@ command refuses rather than waiting. Going private never asks.
162
162
  A public project's page offers only its named versions that have not been
163
163
  hidden (see `cbx promote` and `cbx mark --hide`).
164
164
 
165
+ ## Pages
166
+
167
+ A public project can be served as a website: a Unity WebGL build, a Godot web
168
+ export, a Vite or React build, or plain HTML.
169
+
170
+ cbx pages # on or off, the address, the version and folder it serves
171
+ cbx pages auto # find the build in the published version and turn it on
172
+ cbx pages on --folder Build/WebGL # serve a folder you name
173
+ cbx pages on --spa # unknown paths serve index.html (--no-spa undoes it)
174
+ cbx pages on --isolate # cross-origin isolation, for Godot 4 threads
175
+ cbx pages on --version 12 # serve one version rather than the newest published
176
+ cbx pages off # stop serving it
177
+
178
+ Each project gets an address of its own, `https://<project>--<owner>.<pages domain>/`.
179
+ Until the service's Pages domain is set up, the address reads "goes live when
180
+ the Pages domain is set up".
181
+
182
+ Only published versions are served — a save made public with `cbx promote` or
183
+ from History — and the project must be public. Only its owner can change the
184
+ site. Turning it on asks first, because the site runs the project's code in
185
+ anybody's browser under its name; pass `--yes` in a script. Turning it off
186
+ never asks.
187
+
188
+ When nothing published looks like a site and you run `cbx pages auto` in the
189
+ project's folder, it looks on the disk for a build (`dist/`, `build/`, `out/`,
190
+ `public/`, `Build/WebGL/` and the like). A build the ignore rules leave out is
191
+ named along with the rule responsible, and it offers to add the `.gitignore`
192
+ line that keeps it in (`!/dist/`). It does not save or publish anything: the
193
+ next steps are `cbx submit`, `cbx promote`, then `cbx pages auto` again.
194
+
165
195
  ## Ignore rules
166
196
 
167
197
  ```
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ServiceError = void 0;
3
4
  exports.putObject = putObject;
4
5
  exports.beginUpload = beginUpload;
5
6
  exports.uploadPart = uploadPart;
@@ -22,6 +23,10 @@ exports.cancelMerge = cancelMerge;
22
23
  exports.aiAccess = aiAccess;
23
24
  exports.setAiAccess = setAiAccess;
24
25
  exports.updateProject = updateProject;
26
+ exports.pages = pages;
27
+ exports.setPages = setPages;
28
+ exports.detectPages = detectPages;
29
+ exports.autoPages = autoPages;
25
30
  exports.compareVersions = compareVersions;
26
31
  exports.fileTextAt = fileTextAt;
27
32
  exports.versions = versions;
@@ -70,6 +75,25 @@ const config_js_1 = require("./config.js");
70
75
  * than ending the run.
71
76
  */
72
77
  const REQUEST_DEADLINE_MS = 180_000;
78
+ /**
79
+ * A refusal from the service, with what it said and why.
80
+ *
81
+ * Still an Error carrying the service's own sentence, so every caller that
82
+ * prints `error.message` goes on printing the same words. The status and the
83
+ * code are there for the few commands that answer a particular refusal
84
+ * differently — "not available here yet" is not the same as "not allowed".
85
+ */
86
+ class ServiceError extends Error {
87
+ status;
88
+ code;
89
+ constructor(message, status, code) {
90
+ super(message);
91
+ this.status = status;
92
+ this.code = code;
93
+ this.name = "ServiceError";
94
+ }
95
+ }
96
+ exports.ServiceError = ServiceError;
73
97
  async function call(route, options = {}) {
74
98
  const token = await (0, config_js_1.loadToken)();
75
99
  if (!token) {
@@ -93,7 +117,7 @@ async function call(route, options = {}) {
93
117
  if (response.status === 401) {
94
118
  throw new Error("That token was not accepted. Run: cbx sign-in");
95
119
  }
96
- throw new Error(body?.error?.message ?? `${route} failed (${response.status})`);
120
+ throw new ServiceError(body?.error?.message ?? `${route} failed (${response.status})`, response.status, typeof body?.error?.code === "string" ? body.error.code : null);
97
121
  }
98
122
  return body;
99
123
  }
@@ -388,6 +412,94 @@ async function updateProject(repositoryId, change) {
388
412
  body: change,
389
413
  });
390
414
  }
415
+ function pagesSettings(row) {
416
+ const live = row.live;
417
+ const entry = row.entry;
418
+ return {
419
+ enabled: row.enabled === true,
420
+ folder: String(row.folder ?? ""),
421
+ versionId: row.versionId == null ? null : String(row.versionId),
422
+ spa: row.spa === true,
423
+ isolation: row.isolation === true,
424
+ address: row.address == null ? null : String(row.address),
425
+ domainReady: row.domainReady === true,
426
+ live: live
427
+ ? {
428
+ id: String(live.id ?? ""),
429
+ sequence: Number(live.sequence ?? 0),
430
+ name: live.name == null ? null : String(live.name),
431
+ }
432
+ : null,
433
+ entry: entry
434
+ ? { path: String(entry.path ?? ""), found: entry.found === true }
435
+ : null,
436
+ blocked: row.blocked == null ? null : String(row.blocked),
437
+ };
438
+ }
439
+ const pagesRoute = (repositoryId) => `/v1/repositories/${encodeURIComponent(repositoryId)}/pages`;
440
+ /** What a project's site is set to, and what it is serving. */
441
+ async function pages(repositoryId) {
442
+ return pagesSettings(await call(pagesRoute(repositoryId)));
443
+ }
444
+ /** Change a project's site. Only what is given is sent. */
445
+ async function setPages(repositoryId, change) {
446
+ return pagesSettings(await call(pagesRoute(repositoryId), {
447
+ method: "PUT",
448
+ body: change,
449
+ }));
450
+ }
451
+ /** What the newest published version looks like it could serve. */
452
+ async function detectPages(repositoryId) {
453
+ const body = await call(`${pagesRoute(repositoryId)}/detect`);
454
+ const version = body.version;
455
+ const suggestion = body.suggestion;
456
+ return {
457
+ version: version
458
+ ? {
459
+ id: String(version.id ?? ""),
460
+ sequence: Number(version.sequence ?? 0),
461
+ name: version.name == null ? null : String(version.name),
462
+ }
463
+ : null,
464
+ suggestion: suggestion
465
+ ? {
466
+ folder: String(suggestion.folder ?? ""),
467
+ kind: String(suggestion.kind ?? "static"),
468
+ spa: suggestion.spa === true,
469
+ isolation: suggestion.isolation === true,
470
+ why: String(suggestion.why ?? ""),
471
+ }
472
+ : null,
473
+ candidates: Array.isArray(body.candidates)
474
+ ? body.candidates.map((one) => ({
475
+ folder: String(one.folder ?? ""),
476
+ kind: String(one.kind ?? "static"),
477
+ why: String(one.why ?? ""),
478
+ }))
479
+ : [],
480
+ problem: body.problem == null ? null : String(body.problem),
481
+ };
482
+ }
483
+ /**
484
+ * Detect and apply in one request, so what is switched on is what the
485
+ * service found rather than what this client last saw.
486
+ */
487
+ async function autoPages(repositoryId) {
488
+ const body = await call(`${pagesRoute(repositoryId)}/auto`, { method: "POST" });
489
+ const applied = body.applied;
490
+ return {
491
+ ...pagesSettings(body),
492
+ applied: applied
493
+ ? {
494
+ folder: String(applied.folder ?? ""),
495
+ kind: String(applied.kind ?? "static"),
496
+ spa: applied.spa === true,
497
+ isolation: applied.isolation === true,
498
+ why: String(applied.why ?? ""),
499
+ }
500
+ : null,
501
+ };
502
+ }
391
503
  /**
392
504
  * What changed between two saved versions.
393
505
  *
@@ -27,6 +27,7 @@ const help_js_1 = require("./help.js");
27
27
  const progress_js_1 = require("./progress.js");
28
28
  const publish_js_1 = require("./publish.js");
29
29
  const attach_command_js_1 = require("./attach_command.js");
30
+ const pages_command_js_1 = require("./pages_command.js");
30
31
  const project_commands_js_1 = require("./project_commands.js");
31
32
  const track_commands_js_1 = require("./track_commands.js");
32
33
  const tracks_js_1 = require("../../desktop-app/src/main/tracks.js");
@@ -2323,6 +2324,39 @@ const SPECS = [
2323
2324
  ],
2324
2325
  run: project_commands_js_1.commandVisibility,
2325
2326
  },
2327
+ {
2328
+ name: "pages",
2329
+ group: "Your projects",
2330
+ summary: "serve a public project as a website",
2331
+ usage: "pages [auto|on|off] [project]",
2332
+ detail: "With no word, says whether the project's site is on, its address, which\n" +
2333
+ "version it serves, from which folder, and anything stopping it.\n\n" +
2334
+ "auto finds the build in the newest published version — a Unity WebGL\n" +
2335
+ "build, a Godot web export, a Vite or React build, or plain HTML — and\n" +
2336
+ "turns the site on with it. When nothing published looks like a site and\n" +
2337
+ "you are in the project's folder, it looks here instead: a build left out\n" +
2338
+ "by the ignore rules is named, with the .gitignore line that keeps it in.\n" +
2339
+ "It never saves or publishes for you.\n\n" +
2340
+ "on and auto ask first, because the site runs the project's code in\n" +
2341
+ "anybody's browser under its name. Only published versions are served,\n" +
2342
+ "the project must be public, and only its owner can change the site.\n" +
2343
+ "off asks nothing.",
2344
+ options: [
2345
+ { flags: "--folder <path>", description: "on: where index.html is; . for the top" },
2346
+ { flags: "--spa", description: "on: unknown paths serve index.html (--no-spa undoes)" },
2347
+ { flags: "--isolate", description: "on: cross-origin isolation, for Godot 4 threads (--no-isolate)" },
2348
+ { flags: "--version <n|id>", description: "on: serve this version rather than the newest published" },
2349
+ { flags: "-y, --yes", description: "turn it on, or change .gitignore, without asking" },
2350
+ ],
2351
+ examples: [
2352
+ "cbx pages",
2353
+ "cbx pages auto",
2354
+ "cbx pages on my-game --folder Build/WebGL --isolate",
2355
+ "cbx pages on --version 12 --yes",
2356
+ "cbx pages off",
2357
+ ],
2358
+ run: pages_command_js_1.commandPages,
2359
+ },
2326
2360
  {
2327
2361
  name: "issues",
2328
2362
  group: "Your projects",
@@ -0,0 +1,625 @@
1
+ "use strict";
2
+ /**
3
+ * `cbx pages`: a public project served as a website.
4
+ *
5
+ * Kept in its own file beside the other project commands. It is one command
6
+ * with four answers — what the site is set to, find the build for me, on, off
7
+ * — and the "find it for me" answer reaches onto the disk and into the ignore
8
+ * rules, which none of the account-side commands do.
9
+ *
10
+ * Turning a site on is the step that asks. What it serves is the project's
11
+ * own code, running in a stranger's browser under the project's name, and
12
+ * that deserves a yes from somebody who can give one. Turning it off asks
13
+ * nothing: it takes nothing from anybody who should have it.
14
+ */
15
+ var __importDefault = (this && this.__importDefault) || function (mod) {
16
+ return (mod && mod.__esModule) ? mod : { "default": mod };
17
+ };
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.BUILD_FOLDERS = exports.whereWords = exports.kindWords = void 0;
20
+ exports.splitPages = splitPages;
21
+ exports.normaliseFolder = normaliseFolder;
22
+ exports.explainRefusal = explainRefusal;
23
+ exports.commandPages = commandPages;
24
+ exports.findWebBuilds = findWebBuilds;
25
+ exports.reinclusionFor = reinclusionFor;
26
+ const promises_1 = require("node:fs/promises");
27
+ const node_path_1 = __importDefault(require("node:path"));
28
+ const node_process_1 = __importDefault(require("node:process"));
29
+ const api_js_1 = require("./api.js");
30
+ const config_js_1 = require("./config.js");
31
+ const project_commands_js_1 = require("./project_commands.js");
32
+ const worktree_js_1 = require("../../desktop-app/src/main/worktree.js");
33
+ const rules_js_1 = require("../../desktop-app/src/main/rules.js");
34
+ const colour = node_process_1.default.stdout.isTTY && !node_process_1.default.env.NO_COLOR;
35
+ const dim = (text) => (colour ? `[2m${text}[0m` : text);
36
+ const bold = (text) => (colour ? `[1m${text}[0m` : text);
37
+ const accent = (text) => (colour ? `[33m${text}[0m` : text);
38
+ const red = (text) => (colour ? `[31m${text}[0m` : text);
39
+ const green = (text) => (colour ? `[32m${text}[0m` : text);
40
+ const SUBCOMMANDS = new Set(["status", "auto", "on", "off"]);
41
+ /** `[action] [project]`, where the action is recognised by being one. */
42
+ function splitPages(positional) {
43
+ const [first, second] = positional;
44
+ if (first && SUBCOMMANDS.has(first.toLowerCase())) {
45
+ return {
46
+ action: first.toLowerCase(),
47
+ project: second,
48
+ };
49
+ }
50
+ return { action: "status", project: first };
51
+ }
52
+ /** What a kind of build is called when it is said out loud. */
53
+ const KIND_WORDS = {
54
+ unity: "a Unity WebGL build",
55
+ godot: "a Godot web export",
56
+ vite: "a Vite build",
57
+ next: "a Next.js static export",
58
+ react: "a React build",
59
+ static: "a website",
60
+ };
61
+ const kindWords = (kind) => KIND_WORDS[kind] ?? "a website";
62
+ exports.kindWords = kindWords;
63
+ /** "in dist/", or "at the top of the project" for the empty folder. */
64
+ const whereWords = (folder) => folder ? `in ${folder.replace(/\/+$/, "")}/` : "at the top of the project";
65
+ exports.whereWords = whereWords;
66
+ /**
67
+ * A folder as the service wants it: forward slashes, no leading `./`, no
68
+ * trailing slash, and "" for the top. Typed on Windows it arrives with
69
+ * backslashes, and `.` is how people say "right here".
70
+ */
71
+ function normaliseFolder(typed) {
72
+ const cleaned = typed
73
+ .trim()
74
+ .replace(/\\/g, "/")
75
+ .replace(/^(\.\/)+/, "")
76
+ .replace(/^\/+/, "")
77
+ .replace(/\/+$/, "");
78
+ return cleaned === "." ? "" : cleaned;
79
+ }
80
+ /** A switch given as `--spa`, `--spa=off` or `--no-spa`. */
81
+ function switchValue(parsed, ...names) {
82
+ for (const name of names) {
83
+ const value = parsed.flags.get(name);
84
+ if (value !== undefined) {
85
+ if (value === true)
86
+ return true;
87
+ return !["off", "false", "no", "n", "0"].includes(value.trim().toLowerCase());
88
+ }
89
+ if (parsed.flags.get(`no-${name}`) !== undefined)
90
+ return false;
91
+ }
92
+ return undefined;
93
+ }
94
+ const sure = (parsed) => parsed.flags.get("yes") === true || parsed.flags.get("y") === true;
95
+ /**
96
+ * Say a refusal in a way somebody can act on.
97
+ *
98
+ * The service's own sentence is passed on as it is — it knows why — with a
99
+ * line after it where there is an obvious next command. Two are recognised
100
+ * by status rather than wording: "this service has no Pages yet", which is
101
+ * not the person's fault and not something they can fix, and "only the owner
102
+ * can", which the service says in its own words.
103
+ */
104
+ function explainRefusal(error, projectRef) {
105
+ if (error instanceof api_js_1.ServiceError) {
106
+ if (error.status === 503 ||
107
+ error.code === "pages_not_ready" ||
108
+ (error.status === 404 && error.code === "not_found")) {
109
+ return ["Pages is not available on this service yet."];
110
+ }
111
+ if (error.status === 403) {
112
+ return [
113
+ error.message ||
114
+ "Access denied: only the project's owner or an admin can change its site.",
115
+ ];
116
+ }
117
+ const lines = [error.message];
118
+ if (error.code === "pages_needs_public") {
119
+ lines.push(dim(` Make the project public first: cbx visibility public${projectRef}`));
120
+ }
121
+ else if (error.code === "version_not_public") {
122
+ lines.push(dim(" Only a published version can be served. cbx promote publishes a save."));
123
+ }
124
+ return lines;
125
+ }
126
+ return [error instanceof Error ? error.message : String(error)];
127
+ }
128
+ function refuse(error, projectRef) {
129
+ for (const line of explainRefusal(error, projectRef))
130
+ console.error(red(line));
131
+ return 1;
132
+ }
133
+ /** The settings, laid out the way somebody checks them. */
134
+ function printSettings(project, settings) {
135
+ const label = (text) => ` ${text.padEnd(9)} `;
136
+ console.log(`${bold(project.name)} — Pages is ${settings.enabled ? green("on") : dim("off")}`);
137
+ console.log(label("Address") +
138
+ (settings.address
139
+ ? settings.address
140
+ : dim("goes live when the Pages domain is set up")));
141
+ if (settings.live) {
142
+ const name = settings.live.name ? ` ${bold(settings.live.name)}` : "";
143
+ const which = settings.versionId ? "pinned" : "newest published";
144
+ /* While off it is what would be served, so it is not called "Serving". */
145
+ console.log(label(settings.enabled ? "Serving" : "Would serve") +
146
+ `${accent(`v${settings.live.sequence}`)}${name} ${dim(which)}`);
147
+ }
148
+ else {
149
+ console.log(label("Serving") + dim("nothing published yet"));
150
+ }
151
+ console.log(label("Folder") +
152
+ (settings.folder ? `${settings.folder}/` : "top of the project") +
153
+ (settings.entry ? dim(` (${settings.entry.path})`) : ""));
154
+ console.log(label("Options") +
155
+ `single-page app ${settings.spa ? "yes" : "no"}` +
156
+ dim(" · ") +
157
+ `cross-origin isolation ${settings.isolation ? "yes" : "no"}`);
158
+ if (settings.entry && !settings.entry.found) {
159
+ console.log(accent(` Warning: ${settings.entry.path} is not in ` +
160
+ (settings.live ? `v${settings.live.sequence}` : "the published files") +
161
+ ", so the site has no front page."));
162
+ }
163
+ if (settings.blocked)
164
+ console.log(label("Blocked") + red(settings.blocked));
165
+ }
166
+ /** Pages settings for a project, or null after saying why not. */
167
+ async function current(project, projectRef) {
168
+ try {
169
+ return await (0, api_js_1.pages)(project.id);
170
+ }
171
+ catch (error) {
172
+ refuse(error, projectRef);
173
+ return null;
174
+ }
175
+ }
176
+ /**
177
+ * Show, find, switch on or switch off a project's site.
178
+ */
179
+ async function commandPages(parsed) {
180
+ const { action, project: reference } = splitPages(parsed.positional);
181
+ const project = await (0, project_commands_js_1.resolveProject)(reference);
182
+ if (!project)
183
+ return 1;
184
+ const projectRef = reference ? ` ${reference}` : "";
185
+ if (action === "status") {
186
+ const settings = await current(project, projectRef);
187
+ if (!settings)
188
+ return 1;
189
+ printSettings(project, settings);
190
+ if (!settings.enabled) {
191
+ console.log("");
192
+ console.log(dim(`Turn it on: cbx pages auto${projectRef} finds the build for you`));
193
+ console.log(dim(` or: cbx pages on${projectRef} --folder <path>`));
194
+ }
195
+ return 0;
196
+ }
197
+ if (action === "off") {
198
+ try {
199
+ const settings = await (0, api_js_1.setPages)(project.id, { enabled: false });
200
+ console.log(green(`Pages is off for ${project.name}.`));
201
+ if (settings.address)
202
+ console.log(dim(` ${settings.address} no longer serves anything.`));
203
+ return 0;
204
+ }
205
+ catch (error) {
206
+ return refuse(error, projectRef);
207
+ }
208
+ }
209
+ if (action === "on")
210
+ return turnOn(parsed, project, projectRef);
211
+ return auto(parsed, project, projectRef);
212
+ }
213
+ /** Refused rather than asked when nobody can answer; see commandVisibility. */
214
+ function nobodyToAsk(what) {
215
+ console.error(red(`${what} needs a yes, and nobody is here to give one.`));
216
+ console.error(dim(" Run it in a terminal to be asked, or add --yes."));
217
+ return 1;
218
+ }
219
+ /** Said before the question, so the yes is to something specific. */
220
+ function sayWhatGoesPublic(project, address) {
221
+ console.log(`Pages serves ${bold(project.name)}'s published files as a website` +
222
+ (address ? ` at ${address}` : "") +
223
+ ".");
224
+ console.log(" Anybody can open it, and the code in it runs in their browser under the project's name.");
225
+ if (!address)
226
+ console.log(dim(" It goes live when the Pages domain is set up."));
227
+ }
228
+ function notPublic(project, projectRef) {
229
+ if (project.visibility === "public")
230
+ return null;
231
+ console.error(red(`${project.name} is not public, and only a public project can have a site.`));
232
+ console.error(dim(` Make it public first: cbx visibility public${projectRef}`));
233
+ return 1;
234
+ }
235
+ /** `--version 12`, `--version <id>` or `--version newest`. */
236
+ async function versionFor(project, typed) {
237
+ const text = typed.trim().replace(/^v(?=\d+$)/i, "");
238
+ if (["newest", "latest"].includes(text.toLowerCase()))
239
+ return { versionId: null };
240
+ if (/^\d+$/.test(text)) {
241
+ const saved = await (0, api_js_1.versions)(project.id);
242
+ const found = saved.find((one) => one.sequence === Number(text));
243
+ if (!found)
244
+ return { error: `${project.name} has no v${text}.` };
245
+ return { versionId: found.id };
246
+ }
247
+ return { versionId: text };
248
+ }
249
+ async function turnOn(parsed, project, projectRef) {
250
+ const change = { enabled: true };
251
+ const folder = parsed.flags.get("folder");
252
+ if (folder === true) {
253
+ console.error(red("--folder needs a path, such as --folder dist. Use --folder . for the top."));
254
+ return 1;
255
+ }
256
+ if (typeof folder === "string")
257
+ change.folder = normaliseFolder(folder);
258
+ const spa = switchValue(parsed, "spa");
259
+ if (spa !== undefined)
260
+ change.spa = spa;
261
+ const isolation = switchValue(parsed, "isolate", "isolation");
262
+ if (isolation !== undefined)
263
+ change.isolation = isolation;
264
+ const version = parsed.flags.get("version");
265
+ if (version === true) {
266
+ console.error(red("--version needs a number, an id, or newest."));
267
+ return 1;
268
+ }
269
+ if (typeof version === "string") {
270
+ const found = await versionFor(project, version);
271
+ if ("error" in found) {
272
+ console.error(red(found.error));
273
+ return 1;
274
+ }
275
+ change.versionId = found.versionId;
276
+ }
277
+ const refused = notPublic(project, projectRef);
278
+ if (refused !== null)
279
+ return refused;
280
+ const before = await current(project, projectRef);
281
+ if (!before)
282
+ return 1;
283
+ /*
284
+ Asked only when the site is off. Moving a site that is already public to
285
+ another folder or version changes what it shows, not whether it exists,
286
+ and that decision has already been made by somebody who could make it.
287
+ */
288
+ if (!before.enabled && !sure(parsed)) {
289
+ if (!node_process_1.default.stdin.isTTY)
290
+ return nobodyToAsk(`Turning on Pages for ${project.name}`);
291
+ sayWhatGoesPublic(project, before.address);
292
+ if (!(await (0, project_commands_js_1.agreed)(`Turn on Pages for ${project.name}? [y/N] `))) {
293
+ console.log(dim(`Nothing changed. Pages is still off for ${project.name}.`));
294
+ return 1;
295
+ }
296
+ }
297
+ let settings;
298
+ try {
299
+ settings = await (0, api_js_1.setPages)(project.id, change);
300
+ }
301
+ catch (error) {
302
+ return refuse(error, projectRef);
303
+ }
304
+ console.log(green(before.enabled ? `Pages settings changed for ${project.name}.` : `Pages is on for ${project.name}.`));
305
+ console.log("");
306
+ printSettings(project, settings);
307
+ return 0;
308
+ }
309
+ async function auto(parsed, project, projectRef) {
310
+ let found;
311
+ try {
312
+ found = await (0, api_js_1.detectPages)(project.id);
313
+ }
314
+ catch (error) {
315
+ return refuse(error, projectRef);
316
+ }
317
+ if (found.suggestion) {
318
+ const suggestion = found.suggestion;
319
+ const where = (0, exports.whereWords)(suggestion.folder);
320
+ const inVersion = found.version ? ` of v${found.version.sequence}` : "";
321
+ console.log(`Found ${(0, exports.kindWords)(suggestion.kind)} ${where}${inVersion}.`);
322
+ if (suggestion.why)
323
+ console.log(dim(` ${suggestion.why}`));
324
+ const extras = [
325
+ suggestion.spa ? "as a single-page app" : "",
326
+ suggestion.isolation ? "with cross-origin isolation" : "",
327
+ ].filter(Boolean);
328
+ if (extras.length)
329
+ console.log(dim(` It would be served ${extras.join(", ")}.`));
330
+ const refused = notPublic(project, projectRef);
331
+ if (refused !== null)
332
+ return refused;
333
+ if (!sure(parsed)) {
334
+ if (!node_process_1.default.stdin.isTTY)
335
+ return nobodyToAsk(`Turning on Pages for ${project.name}`);
336
+ const before = await (0, api_js_1.pages)(project.id).catch(() => null);
337
+ sayWhatGoesPublic(project, before?.address ?? null);
338
+ if (!(await (0, project_commands_js_1.agreed)(`Turn on Pages for ${project.name} with this? [y/N] `))) {
339
+ console.log(dim("Nothing changed."));
340
+ return 1;
341
+ }
342
+ }
343
+ let settings;
344
+ try {
345
+ settings = await (0, api_js_1.autoPages)(project.id);
346
+ }
347
+ catch (error) {
348
+ return refuse(error, projectRef);
349
+ }
350
+ console.log(green(`Pages is on for ${project.name}.`));
351
+ console.log("");
352
+ printSettings(project, settings);
353
+ return 0;
354
+ }
355
+ /*
356
+ Nothing to serve in what was published. If this is the project's own
357
+ folder, the build may be sitting right here and simply never have gone
358
+ up — most often because the ignore rules leave build output out, which
359
+ is exactly right for a repository and exactly wrong for a site.
360
+ */
361
+ const root = node_process_1.default.cwd();
362
+ const link = await (0, config_js_1.readLink)(root).catch(() => null);
363
+ if (link?.repositoryId === project.id) {
364
+ const builds = await findWebBuilds(root);
365
+ if (builds.length)
366
+ return offerLocalBuild(parsed, root, builds, projectRef);
367
+ }
368
+ console.log(found.problem ?? "Nothing in the published files looks like a website.");
369
+ return 1;
370
+ }
371
+ // ── the build on the disk ───────────────────────────────────────────────────
372
+ /** Where build tools put a site, in roughly the order they are met. */
373
+ exports.BUILD_FOLDERS = [
374
+ "dist",
375
+ "build",
376
+ "out",
377
+ "public",
378
+ "docs",
379
+ "Build",
380
+ "WebGL",
381
+ "web",
382
+ "html5",
383
+ "export",
384
+ "site",
385
+ "_site",
386
+ "www",
387
+ ];
388
+ async function isFile(target) {
389
+ try {
390
+ return (await (0, promises_1.stat)(target)).isFile();
391
+ }
392
+ catch {
393
+ return false;
394
+ }
395
+ }
396
+ async function namesIn(directory) {
397
+ try {
398
+ return await (0, promises_1.readdir)(directory, { withFileTypes: true });
399
+ }
400
+ catch {
401
+ return [];
402
+ }
403
+ }
404
+ /** Unity's loader beside a Build folder, or Godot's pack beside the page. */
405
+ async function kindOf(directory) {
406
+ for (const entry of await namesIn(node_path_1.default.join(directory, "Build"))) {
407
+ if (entry.isFile() && entry.name.endsWith(".loader.js"))
408
+ return "unity";
409
+ }
410
+ for (const entry of await namesIn(directory)) {
411
+ if (entry.isFile() && entry.name.endsWith(".pck"))
412
+ return "godot";
413
+ }
414
+ return "static";
415
+ }
416
+ /**
417
+ * Folders here holding an index.html, engine builds first.
418
+ *
419
+ * The usual build folders, one level inside them (`Build/WebGL`,
420
+ * `export/web`), and any other top-level folder only when an engine's marker
421
+ * says it is a build — a stray index.html in `templates/` is not a site.
422
+ */
423
+ async function findWebBuilds(root) {
424
+ const found = [];
425
+ const seen = new Set();
426
+ const consider = async (folder, needMarker) => {
427
+ const key = folder.toLowerCase();
428
+ if (seen.has(key))
429
+ return;
430
+ const directory = node_path_1.default.join(root, ...folder.split("/"));
431
+ if (!(await isFile(node_path_1.default.join(directory, "index.html"))))
432
+ return;
433
+ const kind = await kindOf(directory);
434
+ if (needMarker && kind === "static")
435
+ return;
436
+ seen.add(key);
437
+ found.push({ folder, kind });
438
+ };
439
+ const top = (await namesIn(root)).filter((entry) => entry.isDirectory() &&
440
+ !entry.name.startsWith(".") &&
441
+ !(0, rules_js_1.isUncounted)(entry.name) &&
442
+ entry.name !== "node_modules");
443
+ const usual = new Set(exports.BUILD_FOLDERS.map((name) => name.toLowerCase()));
444
+ const ordered = [...top].sort((left, right) => rank(left.name) - rank(right.name) || left.name.localeCompare(right.name));
445
+ function rank(name) {
446
+ const at = exports.BUILD_FOLDERS.findIndex((one) => one.toLowerCase() === name.toLowerCase());
447
+ return at === -1 ? exports.BUILD_FOLDERS.length : at;
448
+ }
449
+ for (const entry of ordered) {
450
+ if (!usual.has(entry.name.toLowerCase()))
451
+ continue;
452
+ await consider(entry.name, false);
453
+ for (const inner of await namesIn(node_path_1.default.join(root, entry.name))) {
454
+ if (inner.isDirectory() && !inner.name.startsWith(".")) {
455
+ await consider(`${entry.name}/${inner.name}`, false);
456
+ }
457
+ }
458
+ }
459
+ for (const entry of ordered) {
460
+ if (usual.has(entry.name.toLowerCase()))
461
+ continue;
462
+ await consider(entry.name, true);
463
+ }
464
+ /* An engine's build is a surer answer than a folder that happens to be
465
+ called dist, so it goes first; otherwise the usual order stands. */
466
+ return found.sort((left, right) => Number(left.kind === "static") - Number(right.kind === "static"));
467
+ }
468
+ /** Characters a gitignore pattern would read as syntax, written as themselves. */
469
+ const literal = (name) => name.replace(/[\\[\]*?!#]/g, "\\$&");
470
+ /** Which file a rule came from, for saying so. */
471
+ function sourceOf(rule, layers, rules) {
472
+ const layer = layers.find((one) => one.rules.includes(rule));
473
+ if (!layer)
474
+ return ".gitignore";
475
+ if (layer.base)
476
+ return `${layer.base}/.gitignore`;
477
+ /* The personal layer, when there is one, is always first. */
478
+ if (rules.local.trim() && layers[0] === layer)
479
+ return ".git/info/exclude";
480
+ return ".gitignore";
481
+ }
482
+ const written = (rule) => `${rule.negated ? "!" : ""}${rule.anchored ? "/" : ""}${rule.pattern}${rule.directoryOnly ? "/" : ""}`;
483
+ /**
484
+ * Whether the ignore rules leave a build's front page out, and what would
485
+ * bring it back.
486
+ *
487
+ * Asked of the same rules, the same layers and the same matcher a save
488
+ * uses, so the answer is the one `cbx submit` will act on. The fix is
489
+ * checked the same way before it is offered: git cannot re-include a file
490
+ * whose folder is excluded, so each excluded folder on the way down gets
491
+ * its own negation, and a rule in a deeper .gitignore — which outranks the
492
+ * project's own — is named rather than argued with.
493
+ */
494
+ async function reinclusionFor(root, folder, rules) {
495
+ const entry = folder ? `${folder}/index.html` : "index.html";
496
+ const tracked = await (0, worktree_js_1.gitTracked)(root);
497
+ if (tracked?.files.has(entry))
498
+ return { excluded: false };
499
+ const layers = await (0, worktree_js_1.collectLayers)(root, rules);
500
+ const deciding = (0, rules_js_1.decidingRule)(entry, false, layers);
501
+ if (!deciding || deciding.negated)
502
+ return { excluded: false };
503
+ const first = { rule: written(deciding), source: sourceOf(deciding, layers, rules) };
504
+ const lines = [];
505
+ const parts = folder ? folder.split("/") : [];
506
+ for (let attempt = 0; attempt <= parts.length + 1; attempt += 1) {
507
+ const shared = withLines(rules.shared, lines);
508
+ const trial = await (0, worktree_js_1.collectLayers)(root, { ...rules, shared });
509
+ const rule = (0, rules_js_1.decidingRule)(entry, false, trial);
510
+ if (!rule || rule.negated)
511
+ return { excluded: true, ...first, lines };
512
+ const source = sourceOf(rule, trial, { ...rules, shared });
513
+ if (source !== ".gitignore" && source !== ".git/info/exclude") {
514
+ return {
515
+ excluded: true,
516
+ ...first,
517
+ lines: null,
518
+ obstacle: `${source} has "${written(rule)}", and a deeper .gitignore outranks the project's own. Change or remove that line there.`,
519
+ };
520
+ }
521
+ /* The outermost folder still left out, or the page itself. */
522
+ let next = null;
523
+ for (let depth = 1; depth <= parts.length; depth += 1) {
524
+ const ancestor = parts.slice(0, depth).join("/");
525
+ if ((0, rules_js_1.excludes)(ancestor, true, trial)) {
526
+ next = `!/${parts.slice(0, depth).map(literal).join("/")}/`;
527
+ break;
528
+ }
529
+ }
530
+ next ??= `!/${[...parts, "index.html"].map(literal).join("/")}`;
531
+ if (lines.includes(next))
532
+ break;
533
+ lines.push(next);
534
+ }
535
+ return {
536
+ excluded: true,
537
+ ...first,
538
+ lines: null,
539
+ obstacle: `A negation in .gitignore does not bring ${entry} back. Change the "${first.rule}" line in ${first.source} instead.`,
540
+ };
541
+ }
542
+ const PAGES_HEADING = "# Kept for CodeRook Pages: the site is served from this build.";
543
+ function withLines(shared, lines) {
544
+ if (!lines.length)
545
+ return shared;
546
+ const body = shared.replace(/\s*$/, "");
547
+ return `${body}${body ? "\n\n" : ""}${PAGES_HEADING}\n${lines.join("\n")}\n`;
548
+ }
549
+ /** Files under a folder the rules would still leave out, a few named. */
550
+ async function stillLeftOut(root, folder, rules) {
551
+ const layers = await (0, worktree_js_1.collectLayers)(root, rules);
552
+ const result = { count: 0, examples: [] };
553
+ const pending = [folder];
554
+ let looked = 0;
555
+ while (pending.length && looked < 20_000) {
556
+ const relative = pending.shift();
557
+ for (const entry of await namesIn(node_path_1.default.join(root, ...relative.split("/")))) {
558
+ const child = relative ? `${relative}/${entry.name}` : entry.name;
559
+ if (entry.isDirectory()) {
560
+ if (!(0, rules_js_1.isUncounted)(entry.name))
561
+ pending.push(child);
562
+ }
563
+ else if (entry.isFile()) {
564
+ looked += 1;
565
+ if ((0, rules_js_1.excludes)(child, false, layers)) {
566
+ result.count += 1;
567
+ if (result.examples.length < 3)
568
+ result.examples.push(child);
569
+ }
570
+ }
571
+ }
572
+ }
573
+ return result;
574
+ }
575
+ function nextSteps(projectRef) {
576
+ console.log("");
577
+ console.log("Then, to serve it:");
578
+ console.log(` 1. cbx submit ${dim("save the folder, build included")}`);
579
+ console.log(` 2. cbx promote ${dim("publish that save (or make it public from History)")}`);
580
+ console.log(` 3. cbx pages auto${projectRef} ${dim("turn Pages on with what it finds")}`);
581
+ }
582
+ async function offerLocalBuild(parsed, root, builds, projectRef) {
583
+ const build = builds[0];
584
+ const label = build.folder ? `${build.folder}/` : "the top of the folder";
585
+ console.log(`Nothing published can be served yet, but ${(0, exports.kindWords)(build.kind)} is here in ${label}.`);
586
+ if (builds.length > 1) {
587
+ console.log(dim(` Also found: ${builds.slice(1).map((one) => `${one.folder}/`).join(", ")}`));
588
+ }
589
+ const rules = await (0, worktree_js_1.readRules)(root);
590
+ const verdict = await reinclusionFor(root, build.folder, rules);
591
+ if (!verdict.excluded) {
592
+ console.log("It is not in the published version: it has not been saved and published yet.");
593
+ nextSteps(projectRef);
594
+ return 0;
595
+ }
596
+ console.log(`Your ignore rules leave ${label} out of every save — "${verdict.rule}" in ${verdict.source}.`);
597
+ console.log(dim(" That is right for a repository, where a build can be rebuilt, and wrong for a site."));
598
+ if (!verdict.lines) {
599
+ console.log(accent(` ${verdict.obstacle}`));
600
+ nextSteps(projectRef);
601
+ return 1;
602
+ }
603
+ console.log("Adding this to .gitignore keeps it in:");
604
+ for (const line of verdict.lines)
605
+ console.log(` ${accent(line)}`);
606
+ if (!sure(parsed)) {
607
+ if (!node_process_1.default.stdin.isTTY)
608
+ return nobodyToAsk("Changing .gitignore");
609
+ if (!(await (0, project_commands_js_1.agreed)("Add it to .gitignore? [y/N] "))) {
610
+ console.log(dim("Nothing changed."));
611
+ return 1;
612
+ }
613
+ }
614
+ const changed = { ...rules, shared: withLines(rules.shared, verdict.lines) };
615
+ await (0, worktree_js_1.writeRules)(root, changed);
616
+ console.log(green(`Added to .gitignore. ${label} now goes up with the next save.`));
617
+ const left = await stillLeftOut(root, build.folder, changed);
618
+ if (left.count) {
619
+ console.log(accent(` ${left.count} file${left.count === 1 ? "" : "s"} in ${label} ` +
620
+ `${left.count === 1 ? "is" : "are"} still left out by other rules, such as ${left.examples.join(", ")}.`));
621
+ console.log(dim(" cbx ignore shows the rules in force."));
622
+ }
623
+ nextSteps(projectRef);
624
+ return 0;
625
+ }
@@ -17,6 +17,7 @@ exports.commandAi = commandAi;
17
17
  exports.readLevel = readLevel;
18
18
  exports.splitVisibility = splitVisibility;
19
19
  exports.offeredPublicly = offeredPublicly;
20
+ exports.agreed = agreed;
20
21
  exports.commandVisibility = commandVisibility;
21
22
  exports.commandVersions = commandVersions;
22
23
  const node_process_1 = __importDefault(require("node:process"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.31.12",
3
+ "version": "0.31.13",
4
4
  "description": "The command line for CodeRook (coderook.com): version control and project hosting for projects of any size. Every save is a compressed snapshot, private or public, never used to train AI.",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "homepage": "https://coderook.com",
@@ -115,6 +115,23 @@ cbx visibility private # back to the people invited
115
115
  exactly that.** `--yes` skips the question the command would otherwise ask
116
116
  them, so only pass it once they have answered it. Going private is always safe.
117
117
 
118
+ ## Serving a project as a website
119
+
120
+ ```bash
121
+ cbx pages # is the site on, its address, what it serves
122
+ cbx pages auto # find the build in what was published, and offer to serve it
123
+ cbx pages on --folder dist # serve a folder of the published version
124
+ cbx pages off # stop serving it
125
+ ```
126
+
127
+ **Never turn Pages on (`auto` or `on`) unless the person has asked for their
128
+ project to be served as a site.** It runs their code in strangers' browsers
129
+ under the project's name. Pass `--yes` only once they have said yes. Only
130
+ published versions are served and the project must be public. When `auto`
131
+ finds a build left out by the ignore rules it offers a `.gitignore` line and
132
+ lists the next steps; it never saves or publishes — ask before doing those.
133
+ Turning Pages off is always safe.
134
+
118
135
  ## When two saves collide
119
136
 
120
137
  If somebody saved while this folder was behind, the second save becomes a merge