ompchamber 3.1.0 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ompchamber",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "description": "Developer web console and diagnostic chamber for AI Oh-My-Pi — Elysia + Preact on Bun.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
@@ -188,7 +188,7 @@ USAGE:
188
188
  COMMANDS:
189
189
  serve Start the OMPChamber web server (default when no command is given)
190
190
  update Update OMPChamber to the latest GitHub release
191
- stop Stop running OMPChamber instances (every one, or --port <port>)
191
+ stop Stop the instances the CLI started (--port/--all to stop any)
192
192
  restart Stop and start again (every CLI-started instance, or --port <port>)
193
193
  status Show running instances (every one, or --port <port>)
194
194
  logs Print or follow server logs (every instance, or --port <port>)
@@ -201,7 +201,7 @@ OPTIONS:
201
201
  --prod Run the production build instead of the dev server
202
202
  --foreground Run the server in the foreground (no daemon)
203
203
  --no-daemon Alias for --foreground
204
- --all Explicit form of the default for stop/restart (every instance)
204
+ --all Every instance, including ones started outside the CLI
205
205
  -c, --check Report whether a newer release exists without installing it
206
206
  --force Reinstall even when already up to date
207
207
  --no-restart Do not restart a running instance after updating
@@ -226,7 +226,8 @@ EXAMPLES:
226
226
  ompchamber serve --prod # Serve the production build
227
227
  ompchamber update --check # Is a newer release available?
228
228
  ompchamber update # Update, then restart a running instance
229
- ompchamber stop # Stop the running instance
229
+ ompchamber stop # Stop the instances the CLI started
230
+ ompchamber stop --port 3000 # Stop the instance on port 3000, whoever started it
230
231
  ompchamber restart # Restart the server
231
232
  ompchamber status # Show whether the server is running
232
233
  ompchamber logs -f -n 200 # Follow the last 200 log lines
@@ -1,19 +1,20 @@
1
1
  // `ompchamber restart` — stop live instances, then serve again.
2
2
  //
3
3
  // Scoping matches `status` and `stop`: without `--port` every live instance is
4
- // restarted on its own recorded port/host/mode, and `--port <port>` restricts
5
- // the restart to that one. With nothing running, the command is simply `serve`.
4
+ // considered, and `--port <port>` restricts the restart to that one. With
5
+ // nothing running, the command is simply `serve`.
6
6
  //
7
7
  // Instances the CLI did not start are left alone, exactly as `ompchamber update`
8
8
  // leaves them: `bun run dev` and `bun run start` own their own process, so
9
- // replacing it would break a loop OMPChamber does not manage. `ompchamber stop`
10
- // is still the deliberate way to end one.
9
+ // replacing it would break a loop OMPChamber does not manage. `ompchamber stop
10
+ // --port <port>` (or `--all`) is the deliberate way to end one.
11
11
 
12
12
  import { findLiveInstance, listLiveInstances, stopInstance } from '@/cli/lib/runtime.js';
13
13
  import { STOP_TIMEOUT_MS } from '@/cli/lib/process-lifecycle.js';
14
14
  import { log, isJson, isQuiet } from '@/cli/lib/output.js';
15
15
  import { run as runServe } from '@/cli/lib/commands/serve.js';
16
- import { isAutoRestartable, skipRestartNote } from '@/server/lib/lifecycle/restart';
16
+ import { skipRestartNote } from '@/server/lib/lifecycle/restart';
17
+ import { isCliManaged } from '@/server/lib/lifecycle/launch-mode';
17
18
 
18
19
  function explicitPort(options) {
19
20
  const raw = options?.port;
@@ -34,7 +35,7 @@ export async function run(options, ctx) {
34
35
  if (targets.length === 0) return runServe(options, ctx);
35
36
 
36
37
  for (const live of targets) {
37
- if (!isAutoRestartable(live.launchMode)) {
38
+ if (!isCliManaged(live.launchMode)) {
38
39
  if (!json && !quiet) log(`Skipping OMPChamber on port ${live.port}: ${skipRestartNote(live.launchMode)}.`);
39
40
  continue;
40
41
  }
@@ -1,22 +1,19 @@
1
- // `ompchamber stop` — stop one or every live OMPChamber instance.
1
+ // `ompchamber stop` — stop OMPChamber instances.
2
+ //
3
+ // Scope follows OWNERSHIP, the same rule `restart` and `update` apply. By
4
+ // default only the instances the CLI started are stopped, because a server
5
+ // running from source is supervised by a launcher the CLI does not own and
6
+ // that launcher exits with the child it spawned: `bun run dev` is a process
7
+ // whose whole job is to mirror the script under it, so signalling the server
8
+ // takes the user's dev loop down with it. `--port <port>` and `--all` are the
9
+ // explicit forms that do end one — naming an instance is a deliberate call,
10
+ // not a default.
2
11
 
3
12
  import { listLiveInstances, findLiveInstance, stopInstance } from '@/cli/lib/runtime.js';
4
13
  import { STOP_TIMEOUT_MS } from '@/cli/lib/process-lifecycle.js';
5
- import { ok, warn, printJson, isJson } from '@/cli/lib/output.js';
6
-
7
- function resolvePort(options) {
8
- const raw = options?.port;
9
- if (raw !== null && raw !== undefined) {
10
- const parsed = Number(raw);
11
- if (Number.isFinite(parsed)) return parsed;
12
- }
13
- const env = Bun.env.OMPCHAMBER_PORT;
14
- if (typeof env === 'string' && env.trim().length > 0) {
15
- const parsed = Number(env.trim());
16
- if (Number.isFinite(parsed)) return parsed;
17
- }
18
- return 3000;
19
- }
14
+ import { ok, warn, log, printJson, isJson, isQuiet } from '@/cli/lib/output.js';
15
+ import { unmanagedReason } from '@/server/lib/lifecycle/launch-mode';
16
+ import { partitionStopTargets } from '@/cli/lib/stop-scope.js';
20
17
 
21
18
  function explicitPort(options) {
22
19
  const raw = options?.port;
@@ -25,30 +22,40 @@ function explicitPort(options) {
25
22
  return Number.isFinite(parsed) ? parsed : null;
26
23
  }
27
24
 
25
+ /** How to end an instance the default scope leaves alone. */
26
+ function stopHint(port) {
27
+ return `stop it with \`ompchamber stop --port ${port}\` or \`ompchamber stop --all\``;
28
+ }
29
+
28
30
  export async function run(options) {
29
31
  const json = isJson();
32
+ const quiet = isQuiet();
30
33
  const requested = explicitPort(options);
34
+ // `--all` is the explicit spelling of "every instance, whoever started it".
35
+ const explicitScope = requested !== null || Boolean(options?.all);
36
+
37
+ const live = requested !== null
38
+ ? [await findLiveInstance(requested)].filter(Boolean)
39
+ : await listLiveInstances();
40
+ const { targets, skipped } = partitionStopTargets(live, explicitScope);
41
+ const skippedReport = skipped.map((entry) => ({ port: entry.port, pid: entry.pid, launchMode: entry.launchMode }));
31
42
 
32
- // No `--port` means every live instance: a CLI server, a dev run and a
33
- // production server are three processes, and `stop` without a port is the
34
- // command that clears all of them. `--all` is the explicit spelling of the
35
- // same default, kept because it reads well in scripts.
36
- let targets;
37
- if (requested !== null) {
38
- const live = await findLiveInstance(requested);
39
- targets = live ? [live] : [];
40
- } else {
41
- targets = await listLiveInstances();
43
+ if (!json && !quiet) {
44
+ for (const entry of skipped) {
45
+ log(`Skipping OMPChamber on port ${entry.port} (pid ${entry.pid}): it ${unmanagedReason(entry.launchMode)} — ${stopHint(entry.port)}.`);
46
+ }
42
47
  }
43
48
 
44
49
  if (targets.length === 0) {
45
50
  if (json) {
46
- printJson({ stopped: [] });
51
+ printJson({ stopped: [], skipped: skippedReport });
47
52
  return;
48
53
  }
49
- warn(requested === null
50
- ? 'No running OMPChamber instances found.'
51
- : `No running OMPChamber instance on port ${requested}.`);
54
+ if (skipped.length === 0) {
55
+ warn(requested === null
56
+ ? 'No running OMPChamber instances found.'
57
+ : `No running OMPChamber instance on port ${requested}.`);
58
+ }
52
59
  return;
53
60
  }
54
61
 
@@ -64,5 +71,5 @@ export async function run(options) {
64
71
  }
65
72
  }
66
73
 
67
- if (json) printJson({ stopped });
74
+ if (json) printJson({ stopped, skipped: skippedReport });
68
75
  }
@@ -10,7 +10,8 @@ import { color, configure, error, log, ok, warn, printJson, isJson, isQuiet } fr
10
10
  import { listLiveInstances, findLiveInstance, stopInstance } from '@/cli/lib/runtime.js';
11
11
  import { STOP_TIMEOUT_MS } from '@/cli/lib/process-lifecycle.js';
12
12
  import { run as runServe } from '@/cli/lib/commands/serve.js';
13
- import { isAutoRestartable, skipRestartNote } from '@/server/lib/lifecycle/restart';
13
+ import { skipRestartNote } from '@/server/lib/lifecycle/restart';
14
+ import { isCliManaged } from '@/server/lib/lifecycle/launch-mode';
14
15
  import { resolveInstallContext, resolveOmpChamberVersion, updateOmpChamber } from '@/server/lib/updates/install';
15
16
 
16
17
  /**
@@ -30,7 +31,7 @@ async function restartInstances(options, ctx) {
30
31
  const restarted = [];
31
32
  const skipped = [];
32
33
  for (const entry of await liveInstances(options)) {
33
- if (!isAutoRestartable(entry.launchMode)) {
34
+ if (!isCliManaged(entry.launchMode)) {
34
35
  skipped.push({ port: entry.port, pid: entry.pid, launchMode: entry.launchMode });
35
36
  log(`Skipping OMPChamber on port ${entry.port}: ${skipRestartNote(entry.launchMode)}.`);
36
37
  continue;
@@ -0,0 +1,23 @@
1
+ // Ownership scope for `ompchamber stop`.
2
+ //
3
+ // Kept separate from the command so the rule is testable without a live
4
+ // server: the default scope is the instances the CLI started, and only an
5
+ // explicit `--port <port>` / `--all` widens it to the ones it did not. That
6
+ // distinction is the whole reason this module exists — a server running from
7
+ // source sits under a launcher that exits with its child, so stopping it by
8
+ // default takes a `bun run dev` loop down with it.
9
+
10
+ import { isCliManaged } from '@/server/lib/lifecycle/launch-mode';
11
+
12
+ /**
13
+ * Split the live instances into the ones `stop` acts on and the ones it leaves
14
+ * alone. `explicit` is true when the user named a port or passed `--all`:
15
+ * naming an instance is a deliberate call, so ownership no longer gates it.
16
+ */
17
+ export function partitionStopTargets(instances, explicit) {
18
+ if (explicit) return { targets: instances, skipped: [] };
19
+ return {
20
+ targets: instances.filter((entry) => isCliManaged(entry.launchMode)),
21
+ skipped: instances.filter((entry) => !isCliManaged(entry.launchMode)),
22
+ };
23
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { partitionStopTargets } from '@/cli/lib/stop-scope.js';
9
+
10
+ const daemon = { port: 3000, pid: 11, launchMode: 'daemon' };
11
+ const foreground = { port: 3001, pid: 12, launchMode: 'foreground' };
12
+ const dev = { port: 3002, pid: 13, launchMode: 'direct' };
13
+ const unknown = { port: 3003, pid: 14, launchMode: 'unknown' };
14
+
15
+ describe('partitionStopTargets', () => {
16
+ // The default scope is ownership, not "everything that answers /api/health".
17
+ // `bun run dev` is a supervisor whose only job is to mirror the script under
18
+ // it, so stopping that server exits the user's dev loop too — the bug this
19
+ // rule exists to prevent.
20
+ test('stops only the instances the CLI started when the scope is implicit', () => {
21
+ const { targets, skipped } = partitionStopTargets([daemon, dev, foreground, unknown], false);
22
+ expect(targets).toEqual([daemon, foreground]);
23
+ expect(skipped).toEqual([dev, unknown]);
24
+ });
25
+
26
+ // Naming an instance — a port or `--all` — is a deliberate call, so the
27
+ // ownership rule no longer gates it.
28
+ test('widens to every instance when the scope is explicit', () => {
29
+ const { targets, skipped } = partitionStopTargets([daemon, dev, unknown], true);
30
+ expect(targets).toEqual([daemon, dev, unknown]);
31
+ expect(skipped).toEqual([]);
32
+ });
33
+
34
+ test('an unreadable launch mode is never stopped implicitly', () => {
35
+ // A record whose launchMode could not be read must not authorise a kill:
36
+ // the safe reading is "not ours".
37
+ const { targets, skipped } = partitionStopTargets([{ port: 4000, pid: 99, launchMode: undefined }], false);
38
+ expect(targets).toEqual([]);
39
+ expect(skipped.length).toBe(1);
40
+ });
41
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { mkdtemp, rm } from 'fs/promises';
8
+ import { tmpdir } from 'os';
9
+ import { join } from 'path';
10
+
11
+ import cssPlugin from '@/server/lib/bundler/css';
12
+
13
+ /**
14
+ * Build one stylesheet through the REAL plugin with the options the client
15
+ * build uses (`target: 'bun'`, `minify: true`), and return the emitted CSS.
16
+ *
17
+ * This is the end-to-end gate for the nesting bug: Bun's minifier re-nests a
18
+ * rule's nested at-rule as `@supports (…) { & { … } }` for a non-browser
19
+ * target, and Chrome then drops it whenever the parent selector names a
20
+ * pseudo-element. Asserting on the flattener's own output cannot catch that —
21
+ * only the bundle the browser is actually served can.
22
+ */
23
+ async function buildThroughPlugin(css: string): Promise<string> {
24
+ const dir = await mkdtemp(join(tmpdir(), 'ompchamber-css-'));
25
+ try {
26
+ await Bun.write(join(dir, 'probe.css'), css);
27
+ const result = await Bun.build({
28
+ entrypoints: [join(dir, 'probe.css')],
29
+ outdir: join(dir, 'out'),
30
+ target: 'bun',
31
+ minify: true,
32
+ plugins: [cssPlugin],
33
+ });
34
+ if (!result.success) throw new Error(result.logs.map((log) => log.message).join('\n'));
35
+ return await Bun.file(join(dir, 'out', 'probe.css')).text();
36
+ } finally {
37
+ await rm(dir, { recursive: true, force: true });
38
+ }
39
+ }
40
+
41
+ describe('css plugin — nested at-rules reach the browser flattened', () => {
42
+ test('a placeholder colour refinement survives as a top-level @supports', async () => {
43
+ const out = await buildThroughPlugin(`.probe::placeholder {
44
+ color: var(--color-ink);
45
+ @supports (color: color-mix(in lab, red, red)) {
46
+ color: color-mix(in oklab, var(--color-ink) 40%, transparent);
47
+ }
48
+ }
49
+ `);
50
+
51
+ // No `&` anywhere: a nested rule under `::placeholder` is inert in Chrome,
52
+ // which leaves the rule's full-opacity fallback as the final colour.
53
+ expect(out).not.toMatch(/(?<!\\)&/);
54
+ expect(out).toContain('@supports');
55
+ expect(out).toContain('::placeholder');
56
+ expect(out).toContain('40%');
57
+ });
58
+
59
+ test('a scrollbar thumb keeps its translucent refinement', async () => {
60
+ const out = await buildThroughPlugin(`::-webkit-scrollbar-thumb {
61
+ background: var(--theme-ink);
62
+ @supports (color: color-mix(in lab, red, red)) {
63
+ background: color-mix(in srgb, var(--theme-ink) 20%, transparent);
64
+ }
65
+ }
66
+ `);
67
+
68
+ expect(out).not.toMatch(/(?<!\\)&/);
69
+ expect(out).toContain('20%');
70
+ });
71
+
72
+ test('declarations written after the at-rule keep their order in the bundle', async () => {
73
+ const out = await buildThroughPlugin(`.probe {
74
+ background: var(--theme-ink);
75
+ @supports (color: color-mix(in lab, red, red)) {
76
+ background: color-mix(in srgb, var(--theme-ink) 20%, transparent);
77
+ }
78
+ border-radius: 3px;
79
+ }
80
+ `);
81
+
82
+ // The trailing declaration must not be hoisted above the at-rule: that
83
+ // would let the at-rule's `background` win over a later one.
84
+ expect(out.indexOf('border-radius')).toBeGreaterThan(out.indexOf('@supports'));
85
+ expect(out).not.toMatch(/(?<!\\)&/);
86
+ });
87
+ });
@@ -42,6 +42,15 @@
42
42
  * never does), so there is no "build finished" moment to flush at. Writing
43
43
  * each source's faces as its `onLoad` returns is order-independent and works
44
44
  * identically under both entry points.
45
+ *
46
+ * 3. **Nested at-rules are flattened** (`flattenNestedAtRules`, registered
47
+ * AFTER Tailwind so it sees the expanded tree). Bun's CSS minifier keeps the
48
+ * nesting it is handed and, for `target: 'bun'`, re-nests a rule's nested
49
+ * at-rule as `@supports (…) { & { … } }` — and Chrome drops a nested rule
50
+ * whose parent selector names a pseudo-element. Tailwind emits its colour
51
+ * fallbacks in exactly that shape, so the whole app's placeholder and
52
+ * scrollbar colours silently collapsed to their fallback. See that module for
53
+ * the measurement; this one only has to run it.
45
54
  */
46
55
 
47
56
  import { basename, dirname, isAbsolute, join, resolve as resolvePath } from 'path';
@@ -51,6 +60,7 @@ import type { BunPlugin } from 'bun';
51
60
 
52
61
  import { FONT_FACE_DIR } from '@/server/lib/assets/font-css.server';
53
62
  import { FONT_ROUTE_PREFIX, packageRoot } from '@/server/lib/assets/fonts.server';
63
+ import { flattenNestedAtRules } from '@/server/lib/bundler/nested-at-rules';
54
64
 
55
65
  /** A `url(...)` whose target is a font file. */
56
66
  const URL_FUNCTION = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
@@ -100,8 +110,13 @@ const plugin: BunPlugin = {
100
110
  build.onLoad({ filter: /\.css$/ }, async (args) => {
101
111
  const source = await Bun.file(args.path).text();
102
112
  // Tailwind first: it parses `@import "tailwindcss"` and `@theme`, and the
103
- // urls it emits are the ones the extraction below has to recognise.
104
- const expanded = await postcss([tailwind()]).process(source, { from: args.path });
113
+ // urls it emits are the ones the extraction below has to recognise. The
114
+ // flattener runs after it, because the nesting it removes is Tailwind's
115
+ // own output — and before the font extraction, so a hoisted face is still
116
+ // recognised as a face.
117
+ const expanded = await postcss([tailwind(), flattenNestedAtRules()]).process(source, {
118
+ from: args.path,
119
+ });
105
120
  const { rest, faces } = splitFontFaces(expanded.css, dirname(args.path));
106
121
  if (faces.length > 0) {
107
122
  const target = join(packageRoot(), FONT_FACE_DIR, `${basename(args.path)}`);
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import postcss from 'postcss';
8
+
9
+ import { flattenNestedAtRules } from '@/server/lib/bundler/nested-at-rules';
10
+
11
+ /** Run the flattener and return the CSS it produced. */
12
+ async function flatten(css: string): Promise<string> {
13
+ const result = await postcss([flattenNestedAtRules()]).process(css, { from: undefined });
14
+ return result.css;
15
+ }
16
+
17
+ /** Every `&` left outside an escaped class name — an unflattened nesting marker. */
18
+ const unescapedAmpersands = (css: string): number => (css.match(/(?<!\\)&/g) ?? []).length;
19
+
20
+ describe('flattenNestedAtRules', () => {
21
+ test('hoists a nested @supports out of a pseudo-element rule', async () => {
22
+ // Tailwind's shape: a fallback declaration, then the color-mix refinement.
23
+ const out = await flatten(
24
+ `.placeholder-ink\\/40::placeholder {
25
+ color: var(--color-ink);
26
+ @supports (color: color-mix(in lab, red, red)) {
27
+ color: color-mix(in oklab, var(--color-ink) 40%, transparent);
28
+ }
29
+ }`,
30
+ );
31
+
32
+ // The at-rule is at the top level and carries the resolved selector itself:
33
+ // a nested rule whose parent names a pseudo-element is dropped by Chrome.
34
+ expect(unescapedAmpersands(out)).toBe(0);
35
+ expect(out).not.toMatch(/::placeholder\s*\{[^}]*@supports/);
36
+ const root = postcss.parse(out);
37
+ const atRules = root.nodes.filter((n) => n.type === 'atrule');
38
+ expect(atRules).toHaveLength(1);
39
+ expect(atRules[0].name).toBe('supports');
40
+ expect(atRules[0].toString()).toContain('.placeholder-ink\\/40::placeholder');
41
+ expect(atRules[0].toString()).toContain('color-mix');
42
+ });
43
+
44
+ test('keeps declaration order, so the cascade is unchanged', async () => {
45
+ const out = await flatten(
46
+ `.a { color: red; @supports (display: block) { color: blue; } border-radius: 3px; }`,
47
+ );
48
+
49
+ const root = postcss.parse(out);
50
+ const order = root.nodes.map((node) =>
51
+ node.toString().replace(/\s+/g, ' '),
52
+ );
53
+ // `border-radius` was written AFTER the at-rule and must still follow it —
54
+ // moving it before would let the at-rule's `color` be overridden.
55
+ expect(order).toHaveLength(3);
56
+ expect(order[0]).toBe('.a { color: red; }');
57
+ expect(order[1]).toContain('@supports');
58
+ expect(order[2]).toBe('.a { border-radius: 3px; }');
59
+ });
60
+
61
+ test('hoists inside the layer that contained the rule, not to the root', async () => {
62
+ const out = await flatten(
63
+ `@layer base { .b::placeholder { color: red; @supports (display: block) { color: blue; } } }`,
64
+ );
65
+ expect(out).not.toMatch(/::placeholder\s*\{[^}]*@supports/);
66
+ expect(unescapedAmpersands(out)).toBe(0);
67
+ // The refinement stays inside `@layer base` — hoisting it past the layer
68
+ // boundary would change which layer wins.
69
+ const layer = postcss.parse(out).nodes.find((n) => n.type === 'atrule' && n.name === 'layer');
70
+ expect(layer?.toString()).toContain('@supports');
71
+ });
72
+
73
+ test('resolves an explicit & and leaves other rules alone', async () => {
74
+ const out = await flatten(
75
+ `.card { color: red; @supports (display: block) { & { color: blue; } } }
76
+ .plain { color: green; }`,
77
+ );
78
+ expect(out).not.toContain('&');
79
+ const root = postcss.parse(out);
80
+ const supports = root.nodes.find((n) => n.type === 'atrule');
81
+ expect(supports?.toString()).toContain('.card');
82
+ expect(root.toString()).toContain('.plain');
83
+ });
84
+
85
+ test('flattens a rule nested inside the hoisted at-rule on the next pass', async () => {
86
+ const out = await flatten(
87
+ `.outer { @supports (display: block) { .inner { color: blue; } } }`,
88
+ );
89
+ expect(unescapedAmpersands(out)).toBe(0);
90
+ // The inner rule's selector is resolved against its parent, not left bare.
91
+ expect(postcss.parse(out).toString()).toContain('.outer .inner');
92
+ });
93
+
94
+ test('leaves an already-flat stylesheet byte-identical', async () => {
95
+ const flat = `.a { color: red; }
96
+ @supports (display: block) { .a { color: blue; } }
97
+ @layer base { .b { color: green; } }`;
98
+ expect(await flatten(flat)).toBe(flat);
99
+ });
100
+ });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Hoist every nested at-rule out of the style rule that contains it.
8
+ *
9
+ * Bun's CSS minifier keeps the nesting it is handed, and for a target that is
10
+ * not the browser (`target: 'bun'`, which this build needs because its
11
+ * entrypoint is the SERVER) it re-nests a rule's nested at-rule into
12
+ * `@supports (…) { & { … } }`. Chrome does not apply a nested rule whose parent
13
+ * selector names a pseudo-element, so `&` under `::placeholder` or
14
+ * `::-webkit-scrollbar-thumb` is dropped and the rule's earlier fallback
15
+ * declaration wins instead — measured on the built bundle: the composer
16
+ * placeholder painted at full ink rather than 40%, every scrollbar thumb opaque
17
+ * rather than 20%.
18
+ *
19
+ * Tailwind v4 emits its colour fallbacks in exactly that shape (`color:
20
+ * var(--x); @supports (color: color-mix(…)) { color: color-mix(…) }`), so one
21
+ * minifier decision reached 16 rules: every placeholder utility in the app and
22
+ * every scrollbar rule, base and xterm included. The rsbuild pipeline this
23
+ * replaced compiled for browsers and flattened the nesting itself.
24
+ *
25
+ * Flattening here is what makes the bundle immune: the emitted CSS is
26
+ * `@supports (…) { .sel::placeholder { … } }` at the top level, which no
27
+ * minifier has anything left to re-nest and every browser applies. Declaration
28
+ * order is preserved exactly — a rule's declarations stay in the segments they
29
+ * were written in, and each at-rule is hoisted to the position it occupied — so
30
+ * the cascade is unchanged.
31
+ */
32
+
33
+ import postcss from 'postcss';
34
+ import type { AtRule, ChildNode, Container, Plugin, Rule } from 'postcss';
35
+
36
+ /** Resolve a nested selector against the rule it was written inside. */
37
+ function resolveSelector(parentSelector: string, nestedSelector: string): string {
38
+ if (nestedSelector.includes('&')) return nestedSelector.replaceAll('&', parentSelector);
39
+ return `${parentSelector} ${nestedSelector}`;
40
+ }
41
+
42
+ /**
43
+ * Resolve `&` in the rules directly inside `container`, descending through
44
+ * at-rules only: `&` still refers to the nearest enclosing STYLE rule, and an
45
+ * intermediate one is hoisted on a later pass.
46
+ *
47
+ * Runs BEFORE `wrapBareDeclarations` — the rule that wraps bare declarations
48
+ * already carries the parent's resolved selector, and resolving it again would
49
+ * turn `::placeholder` into `::placeholder ::placeholder`.
50
+ */
51
+ function resolveAmpersands(container: Container, parentSelector: string): void {
52
+ for (const node of container.nodes ?? []) {
53
+ if (node.type === 'rule') node.selector = resolveSelector(parentSelector, node.selector);
54
+ else if (node.type === 'atrule') resolveAmpersands(node, parentSelector);
55
+ }
56
+ }
57
+
58
+ /** Wrap an at-rule's bare declarations in `parentSelector`, keeping them in place. */
59
+ function wrapBareDeclarations(atRule: AtRule, parentSelector: string): void {
60
+ const nodes = [...(atRule.nodes ?? [])];
61
+ if (!nodes.some((node) => node.type === 'decl')) return;
62
+ const rebuilt: ChildNode[] = [];
63
+ let declarations: ChildNode[] = [];
64
+ const flush = () => {
65
+ if (declarations.length === 0) return;
66
+ const rule = postcss.rule({ selector: parentSelector });
67
+ rule.append(declarations);
68
+ rebuilt.push(rule);
69
+ declarations = [];
70
+ };
71
+ for (const node of nodes) {
72
+ if (node.type === 'decl') declarations.push(node);
73
+ else {
74
+ flush();
75
+ rebuilt.push(node);
76
+ }
77
+ }
78
+ flush();
79
+ atRule.removeAll();
80
+ atRule.append(rebuilt);
81
+ }
82
+
83
+ /** Hoist `rule`'s nested at-rules into its own container. Reports whether it moved any. */
84
+ function hoistNestedAtRules(rule: Rule): boolean {
85
+ const container = rule.parent;
86
+ // Snapshot: reparenting the declarations empties `rule.nodes` mid-iteration.
87
+ const nodes = [...(rule.nodes ?? [])];
88
+ if (!container || !nodes.some((node) => node.type === 'atrule')) return false;
89
+
90
+ const replacement: ChildNode[] = [];
91
+ let declarations: ChildNode[] = [];
92
+ const flush = () => {
93
+ if (declarations.length === 0) return;
94
+ const segment = postcss.rule({ selector: rule.selector, raws: { ...rule.raws } });
95
+ segment.append(declarations);
96
+ replacement.push(segment);
97
+ declarations = [];
98
+ };
99
+
100
+ for (const node of nodes) {
101
+ if (node.type !== 'atrule') {
102
+ declarations.push(node);
103
+ continue;
104
+ }
105
+ flush();
106
+ const hoisted = node.clone();
107
+ resolveAmpersands(hoisted, rule.selector);
108
+ wrapBareDeclarations(hoisted, rule.selector);
109
+ replacement.push(hoisted);
110
+ }
111
+ flush();
112
+
113
+ container.insertBefore(rule, replacement);
114
+ rule.remove();
115
+ return true;
116
+ }
117
+
118
+ /**
119
+ * PostCSS plugin: hoist nested at-rules out of the rules containing them.
120
+ *
121
+ * `OnceExit` rather than `Once` so Tailwind's own pass has already expanded
122
+ * `@theme` and every utility before there is a rule to flatten; register it
123
+ * LAST for the same reason.
124
+ */
125
+ export function flattenNestedAtRules(): Plugin {
126
+ return {
127
+ postcssPlugin: 'ompchamber-flatten-nested-at-rules',
128
+ OnceExit(root) {
129
+ // A hoist can expose another nested at-rule in the segment that received
130
+ // it, so repeat until a full pass moves nothing.
131
+ let hoisted = true;
132
+ while (hoisted) {
133
+ hoisted = false;
134
+ const rules: Rule[] = [];
135
+ root.walkRules((rule) => {
136
+ rules.push(rule);
137
+ });
138
+ // Reverse document order: a nested rule is walked after its parent, so
139
+ // the inner one is flattened first and its parent is then still intact.
140
+ for (let index = rules.length - 1; index >= 0; index -= 1) {
141
+ if (hoistNestedAtRules(rules[index])) hoisted = true;
142
+ }
143
+ }
144
+ },
145
+ };
146
+ }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
 
3
- import { launchModeArg, resolveLaunchMode } from '@/server/lib/lifecycle/launch-mode';
3
+ import { isCliManaged, launchModeArg, resolveLaunchMode } from '@/server/lib/lifecycle/launch-mode';
4
4
 
5
5
  const ENTRY = '/repo/src/server/index.ts';
6
6
 
@@ -30,3 +30,22 @@ describe('resolveLaunchMode', () => {
30
30
  }
31
31
  });
32
32
  });
33
+
34
+ // Every command that would END a server decides with this predicate:
35
+ // `restart`/`update` may replace it, and `stop` stops it by default. Guessing
36
+ // wrong here kills a `bun run dev` the user owns — that launcher exits with its
37
+ // child — or leaves a daemon serving the build that was just replaced.
38
+ describe('isCliManaged', () => {
39
+ test('owns the instances the CLI started', () => {
40
+ expect(isCliManaged('daemon')).toBe(true);
41
+ expect(isCliManaged('foreground')).toBe(true);
42
+ });
43
+
44
+ test('leaves every instance OMPChamber did not start alone', () => {
45
+ // `bun run dev`, `bun run start`, a manual run, a systemd/pm2 unit.
46
+ expect(isCliManaged('direct')).toBe(false);
47
+ // A probed or unreadable record never authorises a kill either.
48
+ expect(isCliManaged('unknown')).toBe(false);
49
+ expect(isCliManaged(undefined)).toBe(false);
50
+ });
51
+ });