@caelo-cms/shared 0.10.60 → 0.10.62

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 (50) hide show
  1. package/dist/auto-update.d.ts +15 -0
  2. package/dist/auto-update.d.ts.map +1 -1
  3. package/dist/auto-update.js +27 -0
  4. package/dist/auto-update.js.map +1 -1
  5. package/dist/cloud-shell-install.d.ts +42 -10
  6. package/dist/cloud-shell-install.d.ts.map +1 -1
  7. package/dist/cloud-shell-install.js +59 -0
  8. package/dist/cloud-shell-install.js.map +1 -1
  9. package/dist/css-scope-scan.d.ts +29 -0
  10. package/dist/css-scope-scan.d.ts.map +1 -0
  11. package/dist/css-scope-scan.js +251 -0
  12. package/dist/css-scope-scan.js.map +1 -0
  13. package/dist/font-assets.d.ts +1 -1
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/interactions.d.ts +65 -13
  19. package/dist/interactions.d.ts.map +1 -1
  20. package/dist/interactions.js +132 -33
  21. package/dist/interactions.js.map +1 -1
  22. package/dist/preview-compose.d.ts.map +1 -1
  23. package/dist/preview-compose.js +15 -19
  24. package/dist/preview-compose.js.map +1 -1
  25. package/dist/release-manifest.d.ts +3 -0
  26. package/dist/release-manifest.d.ts.map +1 -1
  27. package/dist/release-manifest.js +11 -0
  28. package/dist/release-manifest.js.map +1 -1
  29. package/dist/static-security-headers.d.ts +50 -0
  30. package/dist/static-security-headers.d.ts.map +1 -0
  31. package/dist/static-security-headers.js +58 -0
  32. package/dist/static-security-headers.js.map +1 -0
  33. package/dist/version.d.ts +2 -2
  34. package/dist/version.js +1 -1
  35. package/package.json +1 -1
  36. package/src/auto-update.test.ts +25 -0
  37. package/src/auto-update.ts +36 -0
  38. package/src/cloud-shell-install.test.ts +36 -1
  39. package/src/cloud-shell-install.ts +64 -0
  40. package/src/css-scope-scan.test.ts +117 -0
  41. package/src/css-scope-scan.ts +263 -0
  42. package/src/index.ts +2 -0
  43. package/src/interactions.test.ts +61 -0
  44. package/src/interactions.ts +181 -26
  45. package/src/preview-compose.test.ts +87 -1
  46. package/src/preview-compose.ts +16 -21
  47. package/src/release-manifest.ts +12 -0
  48. package/src/static-security-headers.test.ts +60 -0
  49. package/src/static-security-headers.ts +64 -0
  50. package/src/version.ts +1 -1
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Security response headers for uploaded media and the published static
3
+ * site — the one definition the admin's media routes, the staging preview,
4
+ * every static publisher and every provider stack's edge config derive
5
+ * from (#863, #891).
6
+ *
7
+ * An SVG opened directly (`https://<site>/_assets/logo.<hash>.svg`) is a
8
+ * document, not an image: it runs script in the origin it is served from.
9
+ * On the public site that origin also serves `/api/*` (the gateway behind
10
+ * the same load balancer, CLAUDE.md §11.B), on the admin it holds the
11
+ * operator's session. `sanitizeSvg` strips script at upload; this CSP is
12
+ * the line behind it should a construct ever get past the sanitizer: no
13
+ * script, no plugins, no network, and `sandbox` gives the document an
14
+ * opaque origin. Inline styles and `data:` rasters / fonts stay allowed so
15
+ * an SVG renders exactly as before — as a document and as an `<img>`.
16
+ * Browsers apply a CSP only to documents (and workers), so the header
17
+ * changes nothing for an SVG loaded through `<img>` or CSS.
18
+ *
19
+ * Only SVG gets the CSP: raster images, video and fonts cannot run script,
20
+ * and `sandbox` would stop Chrome's built-in viewer from opening a PDF.
21
+ * `nosniff` goes on everything so a browser never reinterprets bytes as a
22
+ * scriptable type the content-type did not declare.
23
+ */
24
+ /** The CSP an SVG document is served with (see the file comment). */
25
+ export declare const SVG_DOCUMENT_CSP = "default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:; sandbox";
26
+ /** `X-Content-Type-Options` value every media / static response carries. */
27
+ export declare const NOSNIFF = "nosniff";
28
+ /** The MIME type the CSP is keyed on. */
29
+ export declare const SVG_CONTENT_TYPE = "image/svg+xml";
30
+ /**
31
+ * Regex (source string, unanchored at the start) matching a URL path that
32
+ * ends in `.svg`, any case. Only syntax shared by ECMAScript and RE2 (no
33
+ * inline flags), so the same string serves Firebase Hosting's `regex`
34
+ * header matcher and `RegExp` here. The static generator names every file
35
+ * by extension (`contentTypeFor` in admin-core), so the extension is what
36
+ * decides the served `image/svg+xml`.
37
+ */
38
+ export declare const SVG_PATH_PATTERN = "\\.[sS][vV][gG]$";
39
+ /** True when `contentType` is SVG (case and parameters such as `; charset=` ignored). */
40
+ export declare function isSvgContentType(contentType: string): boolean;
41
+ /**
42
+ * Security headers for one media / static-file response.
43
+ *
44
+ * @param contentType - the `content-type` the response is served with.
45
+ * @returns lower-case header entries to spread into the response's headers.
46
+ * @example
47
+ * new Response(body, { headers: { "Content-Type": ct, ...mediaSecurityHeaders(ct) } });
48
+ */
49
+ export declare function mediaSecurityHeaders(contentType: string): Record<string, string>;
50
+ //# sourceMappingURL=static-security-headers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"static-security-headers.d.ts","sourceRoot":"","sources":["../src/static-security-headers.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,qEAAqE;AACrE,eAAO,MAAM,gBAAgB,0FAC4D,CAAC;AAE1F,4EAA4E;AAC5E,eAAO,MAAM,OAAO,YAAY,CAAC;AAEjC,yCAAyC;AACzC,eAAO,MAAM,gBAAgB,kBAAkB,CAAC;AAEhD;;;;;;;GAOG;AACH,eAAO,MAAM,gBAAgB,qBAAqB,CAAC;AAEnD,yFAAyF;AACzF,wBAAgB,gBAAgB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAE7D;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAIhF"}
@@ -0,0 +1,58 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /**
3
+ * Security response headers for uploaded media and the published static
4
+ * site — the one definition the admin's media routes, the staging preview,
5
+ * every static publisher and every provider stack's edge config derive
6
+ * from (#863, #891).
7
+ *
8
+ * An SVG opened directly (`https://<site>/_assets/logo.<hash>.svg`) is a
9
+ * document, not an image: it runs script in the origin it is served from.
10
+ * On the public site that origin also serves `/api/*` (the gateway behind
11
+ * the same load balancer, CLAUDE.md §11.B), on the admin it holds the
12
+ * operator's session. `sanitizeSvg` strips script at upload; this CSP is
13
+ * the line behind it should a construct ever get past the sanitizer: no
14
+ * script, no plugins, no network, and `sandbox` gives the document an
15
+ * opaque origin. Inline styles and `data:` rasters / fonts stay allowed so
16
+ * an SVG renders exactly as before — as a document and as an `<img>`.
17
+ * Browsers apply a CSP only to documents (and workers), so the header
18
+ * changes nothing for an SVG loaded through `<img>` or CSS.
19
+ *
20
+ * Only SVG gets the CSP: raster images, video and fonts cannot run script,
21
+ * and `sandbox` would stop Chrome's built-in viewer from opening a PDF.
22
+ * `nosniff` goes on everything so a browser never reinterprets bytes as a
23
+ * scriptable type the content-type did not declare.
24
+ */
25
+ /** The CSP an SVG document is served with (see the file comment). */
26
+ export const SVG_DOCUMENT_CSP = "default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:; sandbox";
27
+ /** `X-Content-Type-Options` value every media / static response carries. */
28
+ export const NOSNIFF = "nosniff";
29
+ /** The MIME type the CSP is keyed on. */
30
+ export const SVG_CONTENT_TYPE = "image/svg+xml";
31
+ /**
32
+ * Regex (source string, unanchored at the start) matching a URL path that
33
+ * ends in `.svg`, any case. Only syntax shared by ECMAScript and RE2 (no
34
+ * inline flags), so the same string serves Firebase Hosting's `regex`
35
+ * header matcher and `RegExp` here. The static generator names every file
36
+ * by extension (`contentTypeFor` in admin-core), so the extension is what
37
+ * decides the served `image/svg+xml`.
38
+ */
39
+ export const SVG_PATH_PATTERN = "\\.[sS][vV][gG]$";
40
+ /** True when `contentType` is SVG (case and parameters such as `; charset=` ignored). */
41
+ export function isSvgContentType(contentType) {
42
+ return (contentType.split(";")[0] ?? "").trim().toLowerCase() === SVG_CONTENT_TYPE;
43
+ }
44
+ /**
45
+ * Security headers for one media / static-file response.
46
+ *
47
+ * @param contentType - the `content-type` the response is served with.
48
+ * @returns lower-case header entries to spread into the response's headers.
49
+ * @example
50
+ * new Response(body, { headers: { "Content-Type": ct, ...mediaSecurityHeaders(ct) } });
51
+ */
52
+ export function mediaSecurityHeaders(contentType) {
53
+ const headers = { "x-content-type-options": NOSNIFF };
54
+ if (isSvgContentType(contentType))
55
+ headers["content-security-policy"] = SVG_DOCUMENT_CSP;
56
+ return headers;
57
+ }
58
+ //# sourceMappingURL=static-security-headers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"static-security-headers.js","sourceRoot":"","sources":["../src/static-security-headers.ts"],"names":[],"mappings":"AAAA,mCAAmC;AAEnC;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,qEAAqE;AACrE,MAAM,CAAC,MAAM,gBAAgB,GAC3B,uFAAuF,CAAC;AAE1F,4EAA4E;AAC5E,MAAM,CAAC,MAAM,OAAO,GAAG,SAAS,CAAC;AAEjC,yCAAyC;AACzC,MAAM,CAAC,MAAM,gBAAgB,GAAG,eAAe,CAAC;AAEhD;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,kBAAkB,CAAC;AAEnD,yFAAyF;AACzF,MAAM,UAAU,gBAAgB,CAAC,WAAmB;IAClD,OAAO,CAAC,WAAW,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,KAAK,gBAAgB,CAAC;AACrF,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,oBAAoB,CAAC,WAAmB;IACtD,MAAM,OAAO,GAA2B,EAAE,wBAAwB,EAAE,OAAO,EAAE,CAAC;IAC9E,IAAI,gBAAgB,CAAC,WAAW,CAAC;QAAE,OAAO,CAAC,yBAAyB,CAAC,GAAG,gBAAgB,CAAC;IACzF,OAAO,OAAO,CAAC;AACjB,CAAC"}
package/dist/version.d.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  * Format: SemVer 2.0.0. Pre-1.0 minor bumps are breaking; post-1.0
20
20
  * follow standard SemVer.
21
21
  */
22
- export declare const CAELO_VERSION = "0.10.60";
22
+ export declare const CAELO_VERSION = "0.10.62";
23
23
  /**
24
24
  * Deprecated alias for back-compat — early P17 work spelled this
25
25
  * `CALEO_VERSION` (typo of "Caelo"). New code should import
@@ -31,7 +31,7 @@ export declare const CAELO_VERSION = "0.10.60";
31
31
  *
32
32
  * @deprecated use CAELO_VERSION
33
33
  */
34
- export declare const CALEO_VERSION = "0.10.60";
34
+ export declare const CALEO_VERSION = "0.10.62";
35
35
  /**
36
36
  * Parsed shape — major/minor/patch + optional pre-release tag.
37
37
  * Stable interface for callers that need to feature-gate (rare —
package/dist/version.js CHANGED
@@ -20,7 +20,7 @@
20
20
  * Format: SemVer 2.0.0. Pre-1.0 minor bumps are breaking; post-1.0
21
21
  * follow standard SemVer.
22
22
  */
23
- export const CAELO_VERSION = "0.10.60";
23
+ export const CAELO_VERSION = "0.10.62";
24
24
  /**
25
25
  * Deprecated alias for back-compat — early P17 work spelled this
26
26
  * `CALEO_VERSION` (typo of "Caelo"). New code should import
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caelo-cms/shared",
3
- "version": "0.10.60",
3
+ "version": "0.10.62",
4
4
  "private": false,
5
5
  "license": "MPL-2.0",
6
6
  "description": "Shared Zod schemas + types + the two-pass HTML composer used by every Caelo CMS workspace package. Stable enough for plugin authors and embedders to depend on directly.",
@@ -58,6 +58,7 @@ function input(over: Partial<AutoUpdateInput> & { version?: string; publishedHou
58
58
  facts: FACTS,
59
59
  upgraderAvailable: true,
60
60
  convergeAvailable: true,
61
+ skippedVersion: null,
61
62
  now: NOW,
62
63
  ...rest,
63
64
  } satisfies AutoUpdateInput;
@@ -376,6 +377,30 @@ describe("decideAutoUpdate — modes and versions", () => {
376
377
  });
377
378
  });
378
379
 
380
+ describe("decideAutoUpdate — the Owner skipped a scheduled install (#868)", () => {
381
+ it("does not install or schedule the skipped release, but still offers it", () => {
382
+ for (const publishedHoursAgo of [48, 2]) {
383
+ const d = decideAutoUpdate(input({ publishedHoursAgo, skippedVersion: VERSIONS.patch }));
384
+ expect(d.action).toBe("notify");
385
+ if (d.action !== "notify") continue;
386
+ expect(d.version).toBe(VERSIONS.patch);
387
+ expect(d.reason).toContain("skipped the automatic install of v0.10.48");
388
+ }
389
+ });
390
+
391
+ it("installs a newer release than the skipped one automatically again", () => {
392
+ const d = decideAutoUpdate(input({ version: "0.10.49", skippedVersion: VERSIONS.patch }));
393
+ expect(d.action).toBe("install");
394
+ });
395
+
396
+ it("leaves every other decision about the skipped release as it was", () => {
397
+ const d = decideAutoUpdate(
398
+ input({ skippedVersion: VERSIONS.patch, facts: { ...FACTS, contractMigrations: true } }),
399
+ );
400
+ expect(d.action).toBe("manual");
401
+ });
402
+ });
403
+
379
404
  describe("nextWindowOpening", () => {
380
405
  it("returns the start of the next window in the window's time zone", () => {
381
406
  const p = { windowStart: "02:00", windowEnd: "05:00", windowTimezone: "America/New_York" };
@@ -37,6 +37,11 @@
37
37
  * 10. soak period not over (security releases: their own, shorter one) → wait;
38
38
  * 11. outside the maintenance window → wait until it opens;
39
39
  * 12. otherwise → install.
40
+ *
41
+ * On top of the rules: a release the Owner skipped ("Skip this time" in
42
+ * Settings → Updates, #868) that the rules would install or schedule is
43
+ * offered instead (notify). Only that version: a newer release installs
44
+ * automatically again, and the skipped one stays installable by hand.
40
45
  */
41
46
 
42
47
  import {
@@ -119,6 +124,11 @@ export interface AutoUpdateInput {
119
124
  readonly convergeAvailable: boolean;
120
125
  /** Why it has none right now, in plain words (null: none set up at all). */
121
126
  readonly upgraderProblem?: string | null;
127
+ /**
128
+ * The release whose automatic install the Owner skipped
129
+ * (`platform_update_scheduler.skipped_version`, #868); null when none.
130
+ */
131
+ readonly skippedVersion: string | null;
122
132
  readonly now: Date;
123
133
  }
124
134
 
@@ -199,8 +209,34 @@ function isStable(version: string): boolean {
199
209
 
200
210
  const hours = (n: number) => `${n} hour${n === 1 ? "" : "s"}`;
201
211
 
212
+ /**
213
+ * The scheduler's sentence for a release whose automatic install the Owner
214
+ * skipped (#868); the skip op writes the same one right away.
215
+ */
216
+ export function skippedInstallReason(version: string): string {
217
+ return `The Owner skipped the automatic install of v${version}. It stays available in Settings → Updates; the next newer release installs automatically again.`;
218
+ }
219
+
202
220
  /** Decide what the scheduler does now. See the file comment for the rules. */
203
221
  export function decideAutoUpdate(input: AutoUpdateInput): AutoUpdateDecision {
222
+ const decision = decideByRules(input);
223
+ if (
224
+ (decision.action === "install" || decision.action === "wait") &&
225
+ input.skippedVersion !== null &&
226
+ decision.version === input.skippedVersion
227
+ ) {
228
+ return {
229
+ action: "notify",
230
+ version: decision.version,
231
+ kind: decision.kind,
232
+ reason: skippedInstallReason(decision.version),
233
+ };
234
+ }
235
+ return decision;
236
+ }
237
+
238
+ /** The numbered rules of the file comment, without the Owner's skip. */
239
+ function decideByRules(input: AutoUpdateInput): AutoUpdateDecision {
204
240
  const { policy, release, now } = input;
205
241
  if (!release) {
206
242
  return { action: "none", reason: "The release check has not found a Caelo release yet." };
@@ -1,7 +1,12 @@
1
1
  // SPDX-License-Identifier: MPL-2.0
2
2
 
3
3
  import { describe, expect, it } from "bun:test";
4
- import { cloudShellInstallUrl } from "./cloud-shell-install.js";
4
+ import {
5
+ CLOUD_SHELL_UPGRADE_FIRST_VERSION,
6
+ cloudShellInstallUrl,
7
+ cloudShellUpgradeCommand,
8
+ cloudShellUpgradeUrl,
9
+ } from "./cloud-shell-install.js";
5
10
 
6
11
  describe("cloudShellInstallUrl", () => {
7
12
  it("pins the release branch of the install repo and the provider's tutorial", () => {
@@ -18,3 +23,33 @@ describe("cloudShellInstallUrl", () => {
18
23
  expect(() => cloudShellInstallUrl("latest", "gcp")).toThrow();
19
24
  });
20
25
  });
26
+
27
+ describe("cloudShellUpgradeUrl (#816)", () => {
28
+ it("opens the release's upgrade walkthrough, never the install one", () => {
29
+ const url = cloudShellUpgradeUrl(CLOUD_SHELL_UPGRADE_FIRST_VERSION, "gcp-firebase");
30
+ expect(url).toBe(
31
+ `https://shell.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https://github.com/caelo-cms/install&cloudshell_git_branch=release-v${CLOUD_SHELL_UPGRADE_FIRST_VERSION}&cloudshell_tutorial=upgrade-gcp-firebase.md`,
32
+ );
33
+ expect(cloudShellUpgradeUrl("v9.0.0", "gcp")).toContain(
34
+ "cloudshell_git_branch=release-v9.0.0&cloudshell_tutorial=upgrade-gcp.md",
35
+ );
36
+ expect(url).not.toContain("tutorial=gcp-firebase.md");
37
+ });
38
+
39
+ it("no link for a release whose branch has no upgrade tutorial, or a pre-release", () => {
40
+ expect(cloudShellUpgradeUrl("0.10.59", "gcp")).toBeNull();
41
+ expect(cloudShellUpgradeUrl("0.11.0-rc.1", "gcp")).toBeNull();
42
+ expect(cloudShellUpgradeUrl("latest", "gcp")).toBeNull();
43
+ });
44
+ });
45
+
46
+ describe("cloudShellUpgradeCommand", () => {
47
+ it("names the install, and its project when known", () => {
48
+ expect(cloudShellUpgradeCommand({ project: "acme-site", install: "acme.com" })).toBe(
49
+ "./run.sh upgrade --project acme-site --install acme.com",
50
+ );
51
+ expect(cloudShellUpgradeCommand({ project: null, install: "acme.com" })).toBe(
52
+ "./run.sh upgrade --install acme.com",
53
+ );
54
+ });
55
+ });
@@ -11,6 +11,8 @@
11
11
  * admin's "run this release in Cloud Shell" hint — so they can't drift.
12
12
  */
13
13
 
14
+ import { isNewerVersion } from "./version.js";
15
+
14
16
  /** The repository the Cloud Shell button clones. */
15
17
  export const CLOUD_SHELL_INSTALL_REPO = "https://github.com/caelo-cms/install";
16
18
 
@@ -45,3 +47,65 @@ export function cloudShellInstallUrl(version: string, provider: CloudShellProvid
45
47
  `&cloudshell_tutorial=${provider}.md`,
46
48
  ].join("");
47
49
  }
50
+
51
+ /**
52
+ * The first release whose `release-v<version>` branch in the install
53
+ * repository ships the break-glass upgrade (#816): `upgrade-gcp.md`,
54
+ * `upgrade-gcp-firebase.md` and `./run.sh upgrade`. Release branches are
55
+ * cut from the install repository's main when the release is tracked and
56
+ * never move, so older branches lack the tutorial; for them the admin shows
57
+ * the CLI command instead of a link that would open nothing.
58
+ *
59
+ * Set to the release that follows caelo-cms/install#3 (the upgrade
60
+ * tutorials) merging; if that merge slips past a release, raise it.
61
+ */
62
+ export const CLOUD_SHELL_UPGRADE_FIRST_VERSION = "0.10.60";
63
+
64
+ /**
65
+ * The Cloud Shell link that opens the break-glass UPGRADE walkthrough of a
66
+ * stable release (#816), never the install wizard.
67
+ *
68
+ * Cloud Shell links cannot carry the project or the install: Google
69
+ * documents no parameter for either, and a tutorial cannot read the URL.
70
+ * `./run.sh upgrade` finds the Owner's install itself (the projects whose
71
+ * ops bucket holds `install/install.json`); the admin shows the explicit
72
+ * `--project … --install …` line ({@link cloudShellUpgradeCommand}) next to
73
+ * the button for an Owner with several installs.
74
+ *
75
+ * @returns null for a pre-release (no pinned branch) or a release before
76
+ * {@link CLOUD_SHELL_UPGRADE_FIRST_VERSION} (its branch has no tutorial)
77
+ *
78
+ * @example cloudShellUpgradeUrl("0.10.60", "gcp-firebase")
79
+ * // → "https://shell.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https://github.com/caelo-cms/install&cloudshell_git_branch=release-v0.10.60&cloudshell_tutorial=upgrade-gcp-firebase.md"
80
+ */
81
+ export function cloudShellUpgradeUrl(version: string, provider: CloudShellProvider): string | null {
82
+ const m = STABLE.exec(version);
83
+ if (!m?.[1]) return null;
84
+ if (isNewerVersion(CLOUD_SHELL_UPGRADE_FIRST_VERSION, m[1])) return null;
85
+ return [
86
+ "https://shell.cloud.google.com/cloudshell/editor",
87
+ `?cloudshell_git_repo=${CLOUD_SHELL_INSTALL_REPO}`,
88
+ `&cloudshell_git_branch=release-v${m[1]}`,
89
+ `&cloudshell_tutorial=upgrade-${provider}.md`,
90
+ ].join("");
91
+ }
92
+
93
+ /**
94
+ * The one line the break-glass walkthrough runs, from the clone of the
95
+ * install repository: `./run.sh upgrade` with the install named when the
96
+ * admin knows it (`--project` from its upgrader job, `--install` = domain).
97
+ *
98
+ * @example cloudShellUpgradeCommand({ project: "acme-site", install: "acme.com" })
99
+ * // → "./run.sh upgrade --project acme-site --install acme.com"
100
+ */
101
+ export function cloudShellUpgradeCommand(target: {
102
+ readonly project: string | null;
103
+ readonly install: string;
104
+ }): string {
105
+ return [
106
+ "./run.sh upgrade",
107
+ ...(target.project ? ["--project", target.project] : []),
108
+ "--install",
109
+ target.install,
110
+ ].join(" ");
111
+ }
@@ -0,0 +1,117 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import { formatCssScopeFindings, moduleRootTokens, scanModuleCssScope } from "./css-scope-scan.js";
5
+
6
+ describe("scanModuleCssScope (issue #158)", () => {
7
+ it("flags element-only selectors as global, at top level and inside @media", () => {
8
+ const f = scanModuleCssScope({
9
+ css: "h1 { color: red } .hero p, body { margin: 0 } @media (max-width: 600px) { a:hover { color: blue } }",
10
+ });
11
+ expect(f.global).toEqual(["h1", "body", "a:hover"]);
12
+ // Without the html there is no root to anchor against.
13
+ expect(f.unanchored).toEqual([]);
14
+ });
15
+
16
+ it("does not count a class inside a pseudo argument as scoping", () => {
17
+ expect(scanModuleCssScope({ css: "h1:not(.x) { color: red }" }).global).toEqual(["h1:not(.x)"]);
18
+ });
19
+
20
+ it("checks selectors against the module root class when the html is known", () => {
21
+ const f = scanModuleCssScope({
22
+ html: '<section class="hero is-dark"><div class="card"></div></section>',
23
+ css: ".hero h1{} .hero__title{} .hero-cta{} html.dark .hero{} .card{} .is-darkish{}",
24
+ });
25
+ expect(f.global).toEqual([]);
26
+ expect(f.unanchored).toEqual([".card", ".is-darkish"]);
27
+ expect(f.rootClasses).toEqual(["hero", "is-dark"]);
28
+ });
29
+
30
+ it("accepts the root id as an anchor", () => {
31
+ const f = scanModuleCssScope({
32
+ html: '<div id="pricing"></div>',
33
+ css: "#pricing .row{} .row{}",
34
+ });
35
+ expect(f.unanchored).toEqual([".row"]);
36
+ expect(f.rootHasNoClass).toBe(false);
37
+ });
38
+
39
+ it("skips blocks that hold no selectors, comments and strings", () => {
40
+ const f = scanModuleCssScope({
41
+ html: '<div class="m"></div>',
42
+ css: '/* h1 {} */ @keyframes spin { from { opacity: 0 } to { opacity: 1 } } @font-face { font-family: X } @import url("x.css"); .m::after { content: "}{ h1 {" } .m { & h2 { color: red } }',
43
+ });
44
+ expect(f.global).toEqual([]);
45
+ expect(f.unanchored).toEqual([]);
46
+ });
47
+
48
+ it("reports a root element with nothing to scope under", () => {
49
+ const f = scanModuleCssScope({ html: "<section><h2>x</h2></section>", css: ".title{}" });
50
+ expect(f.rootHasNoClass).toBe(true);
51
+ expect(formatCssScopeFindings(f)).toContain("root element has no class");
52
+ });
53
+
54
+ it("formats nothing for scoped CSS", () => {
55
+ expect(
56
+ formatCssScopeFindings(
57
+ scanModuleCssScope({ html: '<div class="faq"></div>', css: ".faq summary{}" }),
58
+ ),
59
+ ).toBeNull();
60
+ });
61
+
62
+ it("names the root class in the fix and truncates long lists", () => {
63
+ const text = formatCssScopeFindings(
64
+ scanModuleCssScope({
65
+ html: '<nav class="site-nav"></nav>',
66
+ css: "a{} b{} i{} p{} ul{} li{} em{}",
67
+ }),
68
+ );
69
+ expect(text).toContain("`.site-nav h2`");
70
+ expect(text).toContain("(+2 more)");
71
+ });
72
+ });
73
+
74
+ describe("moduleRootTokens", () => {
75
+ it("reads the first element past comments and ignores templated values", () => {
76
+ expect(
77
+ moduleRootTokens('<!-- hero --> <section class="hero {{variant}}" id="{{anchor}}">'),
78
+ ).toEqual({ classes: ["hero"], id: null });
79
+ expect(moduleRootTokens("just text")).toBeNull();
80
+ expect(moduleRootTokens('<!-- a --><!----> \n<div class="card">')).toEqual({
81
+ classes: ["card"],
82
+ id: null,
83
+ });
84
+ expect(moduleRootTokens('<!-- never closed <div class="card">')).toBeNull();
85
+ });
86
+
87
+ it("stays linear on comment-heavy input (CodeQL js/redos)", () => {
88
+ const started = performance.now();
89
+ expect(moduleRootTokens(`<!--${"--><!--".repeat(50_000)}`)).toBeNull();
90
+ expect(moduleRootTokens(`${"<!---->".repeat(50_000)}<p class="x">`)?.classes).toEqual(["x"]);
91
+ expect(moduleRootTokens("<A\t".repeat(50_000))).toBeNull();
92
+ expect(performance.now() - started).toBeLessThan(1000);
93
+ });
94
+
95
+ it("reads bare and attribute-carrying start tags, and rejects a malformed one", () => {
96
+ expect(moduleRootTokens("<section>")).toEqual({ classes: [], id: null });
97
+ expect(moduleRootTokens('<div\n class="a b"\n id="main">')).toEqual({
98
+ classes: ["a", "b"],
99
+ id: "main",
100
+ });
101
+ expect(moduleRootTokens('<div/class="a">')).toBeNull();
102
+ expect(moduleRootTokens('<div class="a"')).toBeNull();
103
+ });
104
+ });
105
+
106
+ describe("scanModuleCssScope comment stripping", () => {
107
+ it("drops closed comments and treats an unterminated one as running to the end", () => {
108
+ expect(scanModuleCssScope({ css: "/* h1 { x: 1 } */ p { color: red }" }).global).toEqual(["p"]);
109
+ expect(scanModuleCssScope({ css: "p { color: red } /* h1 { x: 1 }" }).global).toEqual(["p"]);
110
+ });
111
+
112
+ it("stays linear on many unterminated comment openers (CodeQL js/polynomial-redos)", () => {
113
+ const started = performance.now();
114
+ scanModuleCssScope({ css: `/*${"a/*".repeat(100_000)}` });
115
+ expect(performance.now() - started).toBeLessThan(1000);
116
+ });
117
+ });