@celilo/cli 0.15.0 → 0.16.2

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.
@@ -30,6 +30,7 @@ see `openspec/specs/`. Companion doc: [CELILO_CORE_MODULES.md](./CELILO_CORE_MOD
30
30
  - **Control-plane network (`secure-mgmt`)** — `apps/celilo/src/hooks/capability-loader.ts` — `loadControlPlaneSubnet` returns the subnet of the zone `celilo-mgmt` is deployed in. `secure-mgmt` is a placement zone AND the control-plane tier, deliberately NOT in `ZONE_TIER_ORDER` (it is not part of the `dmz → app → secure` data-plane chain; it reaches every tier by trust). The firewall's `trustedSubnets` derives from this rather than assuming celilo-mgr sits on `internal`. Reported as an actionable gap by `checkControlPlaneNetwork` in `apps/celilo/src/services/fleet-checks.ts` when the management address matches no configured subnet.
31
31
  - **A deployed system's zone** — `apps/celilo/src/services/deployed-systems.ts` — for machine-pool deploys the zone recorded is the ZONE OF THE MACHINE, not `requires.system.zone` (which is only the minimum used to *select* a host, as with sizing). Three writers must agree: `recordDeployedSystemForModule`, `backfillModuleSystems`, and `apps/celilo/src/variables/context.ts` — the last runs latest and will overwrite the others.
32
32
  - **Host discovery ("which host serves module X?")** — `apps/celilo/src/cli/commands/module-where.ts` (`celilo module where <id> [--json]`, MCP `celilo_module_where`) — reads deployed hosts from `module_systems` via `getModuleSystems`, reconciles the live Proxmox node via `reconcilePlacement`, and adds a role-based reachability hint per zone. CI/build infra (builder VM, Forgejo runners) is out of scope (not in `module_systems`).
33
+ - **Daemon-journal diagnostic ("what does the module's daemon know that celilo doesn't?")** — `apps/celilo/src/services/module-journal.ts` + `apps/celilo/src/cli/commands/module-journal.ts` (`celilo module journal <id> [--unit|--lines|--since|--grep|--json]`, MCP `celilo_module_journal` on the RO principal) — resolves hosts via `getModuleSystems` and reads each one's systemd journal through the `tailLog` remote primitive. General to every module, not one. **READ-ONLY by construction**: `planJournalRead` is pure and the only remote command the op can emit is a `journalctl` read, so it cannot reconfigure a module, send anything, or consume inbound messages the collection path is waiting for (`module-journal.test.ts` asserts on the exact command reaching the SSH seam). Sibling to `celilo module logs`, which reads the LOCAL Ansible deploy log, not the remote daemon. Unit defaults to the glob `<module-id>*`. An unreachable host reports UNREADABLE, never as an empty journal. Rationale: openspec/changes/fix-signal-inbound-delivery Decision 5.
33
34
 
34
35
  ## Capability system (cross-module data & functions)
35
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.15.0",
3
+ "version": "0.16.2",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -59,8 +59,8 @@
59
59
  "@aws-sdk/client-s3": "^3.1024.0",
60
60
  "@celilo/capabilities": "^0.9.1",
61
61
  "@celilo/cli-display": "^0.1.9",
62
- "@celilo/core": "^0.2.0",
63
- "@celilo/event-bus": "^0.1.9",
62
+ "@celilo/core": "^0.3.0",
63
+ "@celilo/event-bus": "^0.1.10",
64
64
  "@clack/prompts": "^1.1.0",
65
65
  "ajv": "^8.18.0",
66
66
  "drizzle-orm": "^0.36.4",
@@ -11,7 +11,13 @@ import { tmpdir } from 'node:os';
11
11
  import { join } from 'node:path';
12
12
  import { type DbClient, getDb } from '../../db/client';
13
13
  import { modules } from '../../db/schema';
14
- import { handleModuleConfigSet } from './module-config';
14
+ import { resolveDeployPosture } from '../../services/deploy-posture';
15
+ import {
16
+ FRAMEWORK_CONFIG_KEYS,
17
+ handleModuleConfigSet,
18
+ validateFrameworkConfigValue,
19
+ } from './module-config';
20
+ import { pickUpgradePolicy } from './module-upgrade';
15
21
 
16
22
  describe('handleModuleConfigSet — infra-key contract (ISS-0069)', () => {
17
23
  let tempDir: string;
@@ -75,4 +81,74 @@ describe('handleModuleConfigSet — infra-key contract (ISS-0069)', () => {
75
81
  expect(result.error).not.toContain('vmid'); // infra keys filtered from the hint
76
82
  }
77
83
  });
84
+
85
+ // #515: these describe how celilo TREATS a module, so they must not depend on
86
+ // the module having declared them. `testmod` declares neither — which is the
87
+ // bug exactly: `upgrade_policy` was declared by NO module, so `always-safe`
88
+ // (the only control over unattended-upgrade risk) could never be set.
89
+ test('accepts upgrade_policy on a module that does not declare it', async () => {
90
+ const result = await handleModuleConfigSet(['testmod', 'upgrade_policy', 'always-safe']);
91
+ expect(result.success).toBe(true);
92
+ });
93
+
94
+ test('accepts auto_upgrade on a module that does not declare it', async () => {
95
+ const result = await handleModuleConfigSet(['testmod', 'auto_upgrade', 'true']);
96
+ expect(result.success).toBe(true);
97
+ });
98
+
99
+ test('rejects a mistyped policy instead of silently falling back to by-semver', async () => {
100
+ // `pickUpgradePolicy` fails OPEN: an unrecognized value becomes `by-semver`,
101
+ // which on a patch means fast posture and NO backup. Accepting this would
102
+ // leave the operator believing the safe floor was armed.
103
+ const result = await handleModuleConfigSet(['testmod', 'upgrade_policy', 'alwayssafe']);
104
+ expect(result.success).toBe(false);
105
+ if (!result.success) {
106
+ expect(result.error).toContain('always-safe');
107
+ }
108
+ });
109
+
110
+ test('rejects a non-boolean auto_upgrade', async () => {
111
+ const result = await handleModuleConfigSet(['testmod', 'auto_upgrade', 'yes']);
112
+ expect(result.success).toBe(false);
113
+ });
114
+
115
+ test('the valid-keys hint advertises the celilo-managed keys', async () => {
116
+ const result = await handleModuleConfigSet(['testmod', 'nope', 'x']);
117
+ expect(result.success).toBe(false);
118
+ if (!result.success) {
119
+ expect(result.error).toContain('upgrade_policy');
120
+ expect(result.error).toContain('auto_upgrade');
121
+ }
122
+ });
123
+ });
124
+
125
+ describe('validateFrameworkConfigValue (pure)', () => {
126
+ test('passes through non-framework keys untouched', () => {
127
+ expect(validateFrameworkConfigValue('app_port', 'anything')).toBeNull();
128
+ });
129
+
130
+ test('accepts every documented value', () => {
131
+ for (const [key, values] of Object.entries(FRAMEWORK_CONFIG_KEYS)) {
132
+ for (const v of values) expect(validateFrameworkConfigValue(key, v)).toBeNull();
133
+ }
134
+ });
135
+
136
+ test('rejects an unlisted value', () => {
137
+ expect(validateFrameworkConfigValue('upgrade_policy', 'always_safe')).toContain('Allowed');
138
+ });
139
+ });
140
+
141
+ // The point of the whole control: with always-safe set, a PATCH upgrade — which
142
+ // by default is fast posture and skips the pre-deploy backup — becomes safe.
143
+ // #515 made this unreachable, so this asserts the chain end to end.
144
+ describe('always-safe actually changes posture on a patch (#515)', () => {
145
+ test('patch is fast by default, safe once always-safe is chosen', () => {
146
+ const patch = { installed: '1.0.3', next: '1.0.4' };
147
+ expect(
148
+ resolveDeployPosture({ ...patch, modulePolicy: pickUpgradePolicy(undefined) }).posture,
149
+ ).toBe('fast');
150
+ expect(
151
+ resolveDeployPosture({ ...patch, modulePolicy: pickUpgradePolicy('always-safe') }).posture,
152
+ ).toBe('safe');
153
+ });
78
154
  });
@@ -14,6 +14,42 @@ import {
14
14
  import { getArg, validateRequiredArgs } from '../parser';
15
15
  import type { CommandResult } from '../types';
16
16
 
17
+ /**
18
+ * Operator keys that EVERY module accepts, whether or not its manifest declares
19
+ * them, with their permitted values.
20
+ *
21
+ * These describe how celilo TREATS a module (its CD policy), not how the module
22
+ * configures itself, so gating them on `variables.owns` had it backwards: it
23
+ * required each module author to opt into being manageable. The failure was
24
+ * silent — `upgrade_policy` was declared by no module at all, so
25
+ * `pickUpgradePolicy()` could only ever read `undefined` and fall back to
26
+ * `by-semver`, leaving `always-safe` (the ONLY control over unattended-upgrade
27
+ * risk) permanently unreachable. `auto_upgrade` worked only because lunacycle
28
+ * happened to declare it. See #515.
29
+ *
30
+ * Values are checked at SET time rather than coerced at read time. Both readers
31
+ * fail OPEN on an unrecognized value — `pickUpgradePolicy()` returns
32
+ * `by-semver`, which for a patch means fast posture and NO backup. So a typo
33
+ * like `alwayssafe` would leave the operator believing they had armed the safe
34
+ * floor while nothing changed. A safety control that fails open on a typo is
35
+ * worse than no control.
36
+ */
37
+ export const FRAMEWORK_CONFIG_KEYS: Record<string, readonly string[]> = {
38
+ auto_upgrade: ['true', 'false'],
39
+ upgrade_policy: ['by-semver', 'always-safe', 'always-fast'],
40
+ };
41
+
42
+ /**
43
+ * PURE (Rule 10.1): validate a framework key's value. Returns an error message,
44
+ * or null when the key is not a framework key or the value is permitted.
45
+ */
46
+ export function validateFrameworkConfigValue(key: string, value: string): string | null {
47
+ const allowed = FRAMEWORK_CONFIG_KEYS[key];
48
+ if (!allowed) return null;
49
+ if (allowed.includes(value)) return null;
50
+ return `Invalid value '${value}' for '${key}'.\n\nAllowed: ${allowed.join(', ')}\n\nRejected rather than coerced: an unrecognized value silently falls back to the PERMISSIVE default (upgrade_policy → by-semver, which skips the pre-deploy backup on a patch), so a typo would look like it took effect.`;
51
+ }
52
+
17
53
  /**
18
54
  * Handle module config set command
19
55
  *
@@ -54,6 +90,11 @@ export async function handleModuleConfigSet(args: string[]): Promise<CommandResu
54
90
  };
55
91
  }
56
92
 
93
+ // Framework keys bypass the manifest check entirely — see FRAMEWORK_CONFIG_KEYS.
94
+ const frameworkError = validateFrameworkConfigValue(key, value);
95
+ if (frameworkError) return { success: false, error: frameworkError };
96
+ const isFrameworkKey = key in FRAMEWORK_CONFIG_KEYS;
97
+
57
98
  // Validate key against manifest
58
99
  const manifest = module.manifestData as Record<string, unknown>;
59
100
  const variables = manifest.variables as
@@ -63,14 +104,15 @@ export async function handleModuleConfigSet(args: string[]): Promise<CommandResu
63
104
 
64
105
  // Check if key is declared in manifest
65
106
  const declaredVar = declaredVars.find((v) => v.name === key);
66
- if (!declaredVar) {
107
+ if (!declaredVar && !isFrameworkKey) {
67
108
  const settableKeys = declaredVars
68
109
  .filter((v) => v.source !== 'infrastructure')
69
110
  .map((v) => v.name)
70
111
  .join(', ');
112
+ const frameworkKeys = Object.keys(FRAMEWORK_CONFIG_KEYS).join(', ');
71
113
  return {
72
114
  success: false,
73
- error: `Invalid config key '${key}' for module ${moduleId}.\n\nValid keys: ${settableKeys || '(none declared)'}`,
115
+ error: `Invalid config key '${key}' for module ${moduleId}.\n\nValid keys: ${settableKeys || '(none declared)'}\nCelilo-managed keys (any module): ${frameworkKeys}`,
74
116
  };
75
117
  }
76
118
 
@@ -79,7 +121,7 @@ export async function handleModuleConfigSet(args: string[]): Promise<CommandResu
79
121
  // variables (vmid, target_ip, target_node, gateway, vlan, lxc_template) are
80
122
  // derived by the deploy (IPAM allocates vmid/IP; the container service supplies
81
123
  // node/template/gateway/vlan), so a value set here would be ignored.
82
- if (declaredVar.source === 'infrastructure') {
124
+ if (declaredVar?.source === 'infrastructure') {
83
125
  return {
84
126
  success: false,
85
127
  error: `'${key}' is infrastructure-managed by celilo (source: infrastructure) — not operator-settable.\nThe deploy derives it automatically, so a value set here would be silently ignored.\n • node placement: set the service default for NEW deploys (celilo service reconfigure); move an existing container with 'celilo proxmox migrate' (ISS-0062).\n • vmid / IP: auto-allocated by IPAM.`,
@@ -0,0 +1,47 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { JournalReport } from '../../services/module-journal';
3
+ import { formatJournalReport } from './module-journal';
4
+
5
+ const system = (over: Partial<JournalReport['systems'][number]> = {}) => ({
6
+ system: 'signal',
7
+ hostname: 'signal',
8
+ ipv4Address: '10.0.20.40',
9
+ unit: 'signal*',
10
+ ok: true,
11
+ lines: [] as string[],
12
+ ...over,
13
+ });
14
+
15
+ describe('formatJournalReport', () => {
16
+ test('renders the journal lines under their host', () => {
17
+ const { message, allOk } = formatJournalReport({
18
+ moduleId: 'signal',
19
+ systems: [system({ lines: ['Received sync sent message'] })],
20
+ });
21
+ expect(allOk).toBe(true);
22
+ expect(message).toContain('signal (10.0.20.40)');
23
+ expect(message).toContain('Received sync sent message');
24
+ });
25
+
26
+ // The defect class of #501: unreadable must not present as quiet.
27
+ test('an unreadable host and an empty journal do not look the same', () => {
28
+ const empty = formatJournalReport({ moduleId: 'signal', systems: [system()] });
29
+ const broken = formatJournalReport({
30
+ moduleId: 'signal',
31
+ systems: [system({ ok: false, error: 'Connection timed out' })],
32
+ });
33
+
34
+ expect(empty.allOk).toBe(true);
35
+ expect(broken.allOk).toBe(false);
36
+ expect(broken.message).not.toBe(empty.message);
37
+ expect(broken.message).toContain('UNREADABLE');
38
+ });
39
+
40
+ test('one unreadable host among several fails the whole read', () => {
41
+ const { allOk } = formatJournalReport({
42
+ moduleId: 'forgejo',
43
+ systems: [system({ lines: ['ok'] }), system({ ok: false, error: 'no route to host' })],
44
+ });
45
+ expect(allOk).toBe(false);
46
+ });
47
+ });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Module journal command — read a deployed module's runtime daemon logs
3
+ * (`journalctl -u <unit>`) through celilo, without SSH to the host.
4
+ *
5
+ * Sibling to `celilo module logs`, not an extension of it: `logs` reads the
6
+ * LOCAL deploy log written by Ansible on celilo-mgr, `journal` reads the
7
+ * REMOTE daemon's journal on the host that runs the module. Different source,
8
+ * different failure modes (a host can be unreachable; a log file cannot), and
9
+ * different answers to different questions. Folding them together would have
10
+ * meant one command whose meaning depended on a flag.
11
+ *
12
+ * Read-only by construction — see services/module-journal.ts.
13
+ */
14
+
15
+ import { getDb } from '../../db/client';
16
+ import { type JournalReport, readModuleJournal } from '../../services/module-journal';
17
+ import { getArg, hasFlag, validateRequiredArgs } from '../parser';
18
+ import type { CommandResult } from '../types';
19
+
20
+ const USAGE =
21
+ 'Usage: celilo module journal <id> [--unit <name>] [--lines <n>] [--since <when>] [--grep <pattern>] [--json]';
22
+
23
+ function numericFlag(value: string | boolean | undefined): number | undefined {
24
+ return typeof value === 'string' ? Number(value) : undefined;
25
+ }
26
+
27
+ function stringFlag(value: string | boolean | undefined): string | undefined {
28
+ return typeof value === 'string' ? value : undefined;
29
+ }
30
+
31
+ /**
32
+ * Handle module journal command.
33
+ */
34
+ export async function handleModuleJournal(
35
+ args: string[],
36
+ flags: Record<string, string | boolean> = {},
37
+ ): Promise<CommandResult> {
38
+ const argError = validateRequiredArgs(args, 1);
39
+ if (argError) {
40
+ return { success: false, error: `${argError}\n\n${USAGE}` };
41
+ }
42
+
43
+ const moduleId = getArg(args, 0);
44
+ if (!moduleId) {
45
+ return { success: false, error: 'Module ID is required' };
46
+ }
47
+
48
+ const report = readModuleJournal(
49
+ {
50
+ moduleId,
51
+ unit: stringFlag(flags.unit),
52
+ lines: numericFlag(flags.lines),
53
+ since: stringFlag(flags.since),
54
+ grep: stringFlag(flags.grep),
55
+ },
56
+ getDb(),
57
+ );
58
+
59
+ if ('error' in report) {
60
+ return { success: false, error: report.error };
61
+ }
62
+
63
+ if (hasFlag(flags, 'json')) {
64
+ return {
65
+ success: true,
66
+ message: JSON.stringify(report, null, 2),
67
+ rawOutput: true,
68
+ data: report,
69
+ };
70
+ }
71
+
72
+ const { message, allOk } = formatJournalReport(report);
73
+ return allOk ? { success: true, message } : { success: false, error: message };
74
+ }
75
+
76
+ /**
77
+ * Presentation. A host celilo could not read is a FAILURE, not a quiet success
78
+ * — the defect this whole change exists to fix is unreadable and empty being
79
+ * byte-identical to the operator.
80
+ */
81
+ export function formatJournalReport(report: JournalReport): { message: string; allOk: boolean } {
82
+ const out: string[] = [];
83
+ for (const sys of report.systems) {
84
+ out.push(`── ${sys.hostname} (${sys.ipv4Address}) · unit ${sys.unit}`);
85
+ if (!sys.ok) {
86
+ out.push(` ✗ UNREADABLE: ${sys.error}`);
87
+ } else if (sys.lines.length === 0) {
88
+ out.push(' (no matching journal lines)');
89
+ } else {
90
+ out.push(...sys.lines.map((line) => ` ${line}`));
91
+ }
92
+ out.push('');
93
+ }
94
+ return {
95
+ message: out.join('\n').trimEnd(),
96
+ allOk: report.systems.every((s) => s.ok),
97
+ };
98
+ }
@@ -181,6 +181,7 @@ export async function getCompletions(words: string[], current: number): Promise<
181
181
  'destroy',
182
182
  'health',
183
183
  'logs',
184
+ 'journal',
184
185
  'run-hook',
185
186
  'secret',
186
187
  'status',
@@ -423,6 +424,7 @@ export async function getCompletions(words: string[], current: number): Promise<
423
424
  'deploy',
424
425
  'destroy',
425
426
  'logs',
427
+ 'journal',
426
428
  'remove',
427
429
  'build',
428
430
  'backup',
@@ -324,6 +324,10 @@ _celilo_config_keys() {
324
324
  fi
325
325
  config_keys+=("$key:$desc_text")
326
326
  done < <(echo "$manifest_json" | jq -r '.variables.owns[]? | "\\(.name)|\\(.description // "No description")|\\(.required // false)"' 2>/dev/null)
327
+ # Celilo-managed keys apply to EVERY module regardless of its manifest
328
+ # (#515), so offer them even when the manifest declares none.
329
+ config_keys+=("auto_upgrade:Let the registry poll upgrade this module unattended (true|false)")
330
+ config_keys+=("upgrade_policy:Deploy posture floor for upgrades (by-semver|always-safe|always-fast)")
327
331
  if [[ \${#config_keys[@]} -gt 0 ]]; then
328
332
  _describe 'config key' config_keys
329
333
  return 0
package/src/cli/index.ts CHANGED
@@ -64,6 +64,7 @@ import { handleModuleDeploy } from './commands/module-deploy';
64
64
  import { handleModuleGenerate } from './commands/module-generate';
65
65
  import { handleModuleHealth } from './commands/module-health';
66
66
  import { handleModuleImport } from './commands/module-import';
67
+ import { handleModuleJournal } from './commands/module-journal';
67
68
  import { handleModuleList } from './commands/module-list';
68
69
  import { handleModuleLogs } from './commands/module-logs';
69
70
  import { handleModuleOperations } from './commands/module-operations';
@@ -1468,6 +1469,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1468
1469
  return handleModuleWhere(parsed.args, parsed.flags);
1469
1470
  case 'logs':
1470
1471
  return handleModuleLogs(parsed.args, parsed.flags);
1472
+ case 'journal':
1473
+ return handleModuleJournal(parsed.args, parsed.flags);
1471
1474
  case 'health':
1472
1475
  return handleModuleHealth(parsed.args, parsed.flags);
1473
1476
  case 'operations':
@@ -1,5 +1,14 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import { type GitCommandRunner, buildReleaseMetadata, collectGitInfo } from './release-metadata';
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import {
7
+ type GitCommandRunner,
8
+ buildReleaseMetadata,
9
+ collectGitInfo,
10
+ makeRealGitRunner,
11
+ } from './release-metadata';
3
12
 
4
13
  describe('buildReleaseMetadata', () => {
5
14
  test('produces a stable shape from injected inputs', () => {
@@ -77,7 +86,7 @@ describe('collectGitInfo', () => {
77
86
  expect(calls).toEqual([
78
87
  ['rev-parse', 'HEAD'],
79
88
  ['rev-parse', '--abbrev-ref', 'HEAD'],
80
- ['status', '--porcelain'],
89
+ ['status', '--porcelain', '--', '.'],
81
90
  ]);
82
91
  });
83
92
 
@@ -101,3 +110,68 @@ describe('collectGitInfo', () => {
101
110
  expect(collectGitInfo('/tmp/x', run).dirty).toBe(true);
102
111
  });
103
112
  });
113
+
114
+ /**
115
+ * Recurrence gate for #544, against a REAL git repo rather than a fake runner.
116
+ *
117
+ * The tests above pin the argv `collectGitInfo` constructs. That is what let the
118
+ * bug live: they asserted which command was built, never what it concluded, so
119
+ * they stayed green while the function reported the whole repository's dirt for
120
+ * every module. A fake `GitCommandRunner` cannot catch this at all — the defect
121
+ * IS git's real scoping behavior, which a stub by definition does not model.
122
+ *
123
+ * So this drives the real `makeRealGitRunner()` against a real checkout. Delete
124
+ * the `-- .` pathspec and the first test here fails; that is the whole point.
125
+ */
126
+ describe('collectGitInfo scopes dirt to sourceDir (#544, real git)', () => {
127
+ let repo: string;
128
+ let moduleDir: string;
129
+ const git = (args: string[], cwd: string) =>
130
+ execFileSync('git', args, { cwd, encoding: 'utf-8' });
131
+
132
+ beforeEach(() => {
133
+ repo = mkdtempSync(join(tmpdir(), 'celilo-gitinfo-'));
134
+ moduleDir = join(repo, 'modules', 'probe');
135
+ mkdirSync(moduleDir, { recursive: true });
136
+ writeFileSync(join(moduleDir, 'manifest.yml'), 'id: probe\n');
137
+ writeFileSync(join(repo, 'bun.lock'), 'lockfile v1\n');
138
+
139
+ git(['init', '-q'], repo);
140
+ git(['config', 'user.email', 'test@celilo.test'], repo);
141
+ git(['config', 'user.name', 'Test'], repo);
142
+ git(['add', '-A'], repo);
143
+ git(['commit', '-qm', 'init'], repo);
144
+ });
145
+
146
+ afterEach(() => rmSync(repo, { recursive: true, force: true }));
147
+
148
+ test('a tracked file rewritten OUTSIDE the module does not make it dirty', () => {
149
+ // Exactly what broke the release: `bun install` rewrites the tracked root
150
+ // `bun.lock` between module publishes. The module itself is untouched.
151
+ writeFileSync(join(repo, 'bun.lock'), 'lockfile v1\nrewritten by bun install\n');
152
+
153
+ expect(collectGitInfo(moduleDir, makeRealGitRunner()).dirty).toBe(false);
154
+ });
155
+
156
+ test("a sibling module's dirt does not make this module dirty", () => {
157
+ const sibling = join(repo, 'modules', 'other');
158
+ mkdirSync(sibling, { recursive: true });
159
+ writeFileSync(join(sibling, 'manifest.yml'), 'id: other\n');
160
+
161
+ expect(collectGitInfo(moduleDir, makeRealGitRunner()).dirty).toBe(false);
162
+ });
163
+
164
+ test("the module's OWN dirt still blocks the publish", () => {
165
+ // The control. Scoping must not have simply disabled the check — this is
166
+ // the case the guard exists for, and it must still fire.
167
+ writeFileSync(join(moduleDir, 'manifest.yml'), 'id: probe\nversion: 9.9.9\n');
168
+
169
+ expect(collectGitInfo(moduleDir, makeRealGitRunner()).dirty).toBe(true);
170
+ });
171
+
172
+ test('an untracked file inside the module still blocks the publish', () => {
173
+ writeFileSync(join(moduleDir, 'stray.txt'), 'oops\n');
174
+
175
+ expect(collectGitInfo(moduleDir, makeRealGitRunner()).dirty).toBe(true);
176
+ });
177
+ });
@@ -129,7 +129,17 @@ export function collectGitInfo(sourceDir: string, run: GitCommandRunner): GitInf
129
129
  // `git status --porcelain` prints one line per modified/untracked file.
130
130
  // Empty output = clean. Null (command failure) is treated as not-dirty
131
131
  // because we don't want to falsely block a publish.
132
- const status = run(['status', '--porcelain'], sourceDir);
132
+ //
133
+ // The `-- .` pathspec is load-bearing (#544). `git status` reports the WHOLE
134
+ // repository regardless of cwd, so passing `sourceDir` as cwd scoped nothing:
135
+ // every caller asks about one module, and got back the dirt of all of them
136
+ // plus the repo root. The release pipeline runs `bun install` between module
137
+ // publishes, which rewrites the tracked `bun.lock` at the root — so modules
138
+ // 1..N published fine and the next one failed with "Working tree at
139
+ // modules/<clean-module> has uncommitted changes", naming a directory that
140
+ // was clean and sending you to inspect it. Order-dependent, so it looked
141
+ // like a random module failing.
142
+ const status = run(['status', '--porcelain', '--', '.'], sourceDir);
133
143
  const dirty = status !== null && status.length > 0;
134
144
 
135
145
  return {