@smoothbricks/cli 0.11.11 → 0.11.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.
Files changed (37) hide show
  1. package/README.md +104 -20
  2. package/dist/lib/json.d.ts +51 -0
  3. package/dist/lib/json.d.ts.map +1 -1
  4. package/dist/lib/json.js +111 -67
  5. package/dist/monorepo/cargo-policy.d.ts +25 -1
  6. package/dist/monorepo/cargo-policy.d.ts.map +1 -1
  7. package/dist/monorepo/cargo-policy.js +396 -4
  8. package/dist/monorepo/ci-workflow.d.ts +27 -2
  9. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  10. package/dist/monorepo/ci-workflow.js +151 -11
  11. package/dist/monorepo/index.d.ts.map +1 -1
  12. package/dist/monorepo/index.js +5 -1
  13. package/dist/monorepo/managed-files.d.ts +3 -1
  14. package/dist/monorepo/managed-files.d.ts.map +1 -1
  15. package/dist/monorepo/managed-files.js +3 -0
  16. package/dist/monorepo/packs/index.d.ts.map +1 -1
  17. package/dist/monorepo/packs/index.js +13 -0
  18. package/dist/monorepo/publish-workflow.d.ts +8 -1
  19. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  20. package/dist/monorepo/publish-workflow.js +15 -5
  21. package/managed/raw/tooling/direnv/devenv.smoo.nix +28 -2
  22. package/managed/raw/tooling/direnv/secret-references.ts +248 -51
  23. package/managed/templates/github/actions/save-nix-devenv/action.yml +2 -2
  24. package/managed/templates/github/actions/setup-devenv/action.yml +43 -5
  25. package/package.json +2 -2
  26. package/src/lib/json.ts +52 -0
  27. package/src/monorepo/__tests__/ci-workflow.test.ts +183 -0
  28. package/src/monorepo/__tests__/publish-workflow.test.ts +29 -0
  29. package/src/monorepo/cargo-policy.test.ts +206 -3
  30. package/src/monorepo/cargo-policy.ts +341 -2
  31. package/src/monorepo/ci-workflow.ts +180 -11
  32. package/src/monorepo/index.ts +5 -1
  33. package/src/monorepo/managed-files.ts +6 -0
  34. package/src/monorepo/packs/index.test.ts +32 -0
  35. package/src/monorepo/packs/index.ts +13 -0
  36. package/src/monorepo/publish-workflow.ts +24 -4
  37. package/src/monorepo/secret-references.test.ts +175 -2
@@ -6,6 +6,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
6
6
  import { readFile } from 'node:fs/promises';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
+ import type { PackageCargoGitOrigin } from '../../lib/json.js';
9
10
  import {
10
11
  type CiWorkflowDefinitionOptions,
11
12
  CiWorkflowStepKind,
@@ -317,6 +318,115 @@ describe('CI workflow definition', () => {
317
318
  }
318
319
  });
319
320
 
321
+ it('rewrites every declared SSH spelling of a mirrored forge onto the same mirror', () => {
322
+ const directory = mkdtempSync(join(tmpdir(), 'cargo-ssh-'));
323
+ try {
324
+ const lines = cargoCredentialStepLines(
325
+ { kind: CiWorkflowStepKind.CargoCredentials, name: 'Credentials', number: 3 },
326
+ {
327
+ gitOrigins: [
328
+ {
329
+ origin: 'https://git.example.net',
330
+ tokenEnv: 'SOURCE_READ_TOKEN',
331
+ internalMirror: 'http://10.89.0.1:3000',
332
+ // Both spellings a lockfile can carry: git matches insteadOf
333
+ // prefixes textually and infers neither from the other.
334
+ sshOrigins: ['ssh://forgejo@forge.example.net:2223/', 'ssh://forge.example.net:2223'],
335
+ },
336
+ ],
337
+ },
338
+ );
339
+ const script = lines
340
+ .slice(lines.indexOf(' run: |') + 1)
341
+ .map((line) => line.slice(10))
342
+ .join('\n');
343
+ const githubEnv = join(directory, 'env');
344
+ writeFileSync(githubEnv, '');
345
+ const environment = {
346
+ PATH: process.env.PATH ?? '/usr/bin:/bin',
347
+ RUNNER_TEMP: directory,
348
+ GITHUB_ENV: githubEnv,
349
+ SOURCE_READ_TOKEN: 'fixture-secret',
350
+ };
351
+ const prepared = spawnSync('sh', ['-eu', '-c', script], { env: environment, encoding: 'utf8' });
352
+ expect(prepared.status).toBe(0);
353
+ const written = readFileSync(githubEnv, 'utf8');
354
+ // One insteadOf pair per spelling, all pointing at the one mirror, and a
355
+ // missing trailing slash is normalized: `ssh://host:2223` would rewrite
356
+ // `ssh://host:2223-other/` too.
357
+ expect(written).toContain('GIT_CONFIG_KEY_2=url.http://10.89.0.1:3000/.insteadOf');
358
+ expect(written).toContain('GIT_CONFIG_VALUE_2=https://git.example.net/');
359
+ expect(written).toContain('GIT_CONFIG_KEY_3=url.http://10.89.0.1:3000/.insteadOf');
360
+ expect(written).toContain('GIT_CONFIG_VALUE_3=ssh://forgejo@forge.example.net:2223/');
361
+ expect(written).toContain('GIT_CONFIG_KEY_4=url.http://10.89.0.1:3000/.insteadOf');
362
+ expect(written).toContain('GIT_CONFIG_VALUE_4=ssh://forge.example.net:2223/');
363
+ expect(written).toContain('GIT_CONFIG_COUNT=5');
364
+ const helper = join(directory, 'cargo-git-credential.sh');
365
+ const ask = (protocol: string, host: string): string => {
366
+ const result = spawnSync('sh', [helper, 'get'], {
367
+ env: environment,
368
+ encoding: 'utf8',
369
+ input: `protocol=${protocol}\nhost=${host}\n\n`,
370
+ });
371
+ expect(result.status).toBe(0);
372
+ return result.stdout;
373
+ };
374
+ // The rewrite happens before transport, so git only ever asks for the
375
+ // mirror. The SSH spelling stays credential-free: an SSH origin the
376
+ // runner cannot reach must fail loudly, not collect a token.
377
+ expect(ask('http', '10.89.0.1:3000')).toBe('username=x-access-token\npassword=fixture-secret\n');
378
+ expect(ask('ssh', 'forge.example.net:2223')).toBe('');
379
+ expect(ask('ssh', 'forgejo@forge.example.net:2223')).toBe('');
380
+ } finally {
381
+ rmSync(directory, { recursive: true, force: true });
382
+ }
383
+ });
384
+
385
+ it('refuses SSH spellings no mirror rewrites, and spellings git cannot match as a prefix', () => {
386
+ const origin = { origin: 'https://git.example.net', tokenEnv: 'SOURCE_READ_TOKEN' };
387
+ const render = (entry: PackageCargoGitOrigin): string[] =>
388
+ cargoCredentialStepLines(
389
+ { kind: CiWorkflowStepKind.CargoCredentials, name: 'Credentials', number: 3 },
390
+ { gitOrigins: [entry] },
391
+ );
392
+
393
+ // An sshOrigins entry only means anything as a rewrite target.
394
+ expect(() => render({ ...origin, sshOrigins: ['ssh://forge.example.net:2223/'] })).toThrow('internalMirror');
395
+ const mirrored = { ...origin, internalMirror: 'http://10.89.0.1:3000' };
396
+ for (const sshOrigins of [
397
+ [],
398
+ // scp syntax is not a URL prefix git can rewrite from a Cargo pin.
399
+ ['forgejo@forge.example.net:axe/minigraf.git'],
400
+ // A path would rewrite one repository, not the forge.
401
+ ['ssh://forge.example.net:2223/axe/minigraf.git'],
402
+ // A password in a declared origin is a secret in package.json.
403
+ ['ssh://forgejo:hunter2@forge.example.net:2223/'],
404
+ ['https://git.example.net/'],
405
+ // Two identical spellings make the same key ambiguous.
406
+ ['ssh://forge.example.net:2223/', 'ssh://forge.example.net:2223'],
407
+ ]) {
408
+ expect(() => render({ ...mirrored, sshOrigins })).toThrow('sshOrigins');
409
+ }
410
+ // One spelling, two mirrors: git keeps whichever identical key it read
411
+ // last, so the declaration is refused instead of resolved.
412
+ expect(() =>
413
+ cargoCredentialStepLines(
414
+ { kind: CiWorkflowStepKind.CargoCredentials, name: 'Credentials', number: 3 },
415
+ {
416
+ gitOrigins: [
417
+ { ...mirrored, sshOrigins: ['ssh://forge.example.net:2223/'] },
418
+ {
419
+ origin: 'https://git.other.net',
420
+ tokenEnv: 'OTHER_READ_TOKEN',
421
+ internalMirror: 'http://10.89.0.2:3000',
422
+ sshOrigins: ['ssh://forge.example.net:2223/'],
423
+ },
424
+ ],
425
+ },
426
+ ),
427
+ ).toThrow('sshOrigins');
428
+ });
429
+
320
430
  it('refuses malformed internal mirrors at render time', () => {
321
431
  for (const internalMirror of [
322
432
  'http://git.example.net/private/repo.git',
@@ -333,6 +443,79 @@ describe('CI workflow definition', () => {
333
443
  }
334
444
  });
335
445
 
446
+ it('gives every Nx job the declared remote cache at the address its runners reach', () => {
447
+ const definition = options({
448
+ deploy: true,
449
+ e2eDeployment: true,
450
+ productionOnPush: true,
451
+ remoteCache: {
452
+ server: 'https://nx-cache.example.net',
453
+ internalServer: 'http://10.89.0.1:8765',
454
+ tokenSecret: 'NX_REMOTE_CACHE_TOKEN',
455
+ },
456
+ });
457
+ const rendered = renderCiWorkflowYaml(definition);
458
+ const cacheEnv = {
459
+ NX_SELF_HOSTED_REMOTE_CACHE_SERVER: 'http://10.89.0.1:8765',
460
+ NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN: '${{ secrets.NX_REMOTE_CACHE_TOKEN }}',
461
+ };
462
+
463
+ expect(Bun.YAML.parse(rendered)).toMatchObject({
464
+ jobs: {
465
+ main: {
466
+ env: cacheEnv,
467
+ // A fork PR receives no secrets, and Nx fails every task a cache
468
+ // server refuses, so the job cannot run there at all.
469
+ if: "${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}",
470
+ },
471
+ 'e2e-deployment': { env: cacheEnv },
472
+ 'deploy-production': { env: cacheEnv },
473
+ },
474
+ });
475
+ // The public origin belongs to shells outside the runner network; a job
476
+ // that took it would leave the internal address unused.
477
+ expect(rendered).not.toContain('https://nx-cache.example.net');
478
+ });
479
+
480
+ it('takes the public server without an internal runner, and emits nothing without the declaration', () => {
481
+ const publicOnly = renderCiWorkflowYaml(
482
+ options({ remoteCache: { server: 'https://nx-cache.example.net', tokenSecret: 'NX_REMOTE_CACHE_TOKEN' } }),
483
+ );
484
+
485
+ expect(Bun.YAML.parse(publicOnly)).toMatchObject({
486
+ jobs: { main: { env: { NX_SELF_HOSTED_REMOTE_CACHE_SERVER: 'https://nx-cache.example.net' } } },
487
+ });
488
+ expect(renderCiWorkflowYaml(options())).not.toContain('NX_SELF_HOSTED');
489
+ // Without a cache token the job needs no secrets, so fork PRs still run.
490
+ expect(renderCiWorkflowYaml(options())).not.toContain('head.repo.full_name');
491
+ });
492
+
493
+ it('refuses a remote cache declaration Nx could not use, at render time', () => {
494
+ for (const server of [
495
+ 'https://nx-cache.example.net/',
496
+ 'https://nx-cache.example.net/cache',
497
+ 'https://token@nx-cache.example.net',
498
+ 'ftp://nx-cache.example.net',
499
+ 'nx-cache.example.net:8765',
500
+ ]) {
501
+ expect(() =>
502
+ renderCiWorkflowYaml(options({ remoteCache: { server, tokenSecret: 'NX_REMOTE_CACHE_TOKEN' } })),
503
+ ).toThrow('smoo.remoteCache server');
504
+ }
505
+ const server = 'https://nx-cache.example.net';
506
+ expect(() =>
507
+ renderCiWorkflowYaml(
508
+ options({ remoteCache: { server, internalServer: 'http://10.89.0.1:8765/', tokenSecret: 'TOKEN' } }),
509
+ ),
510
+ ).toThrow('smoo.remoteCache internalServer');
511
+ expect(() =>
512
+ renderCiWorkflowYaml(options({ remoteCache: { server, internalServer: server, tokenSecret: 'TOKEN' } })),
513
+ ).toThrow('repeats server');
514
+ expect(() =>
515
+ renderCiWorkflowYaml(options({ remoteCache: { server, tokenSecret: 'nx_remote_cache_token' } })),
516
+ ).toThrow('tokenSecret');
517
+ });
518
+
336
519
  it('refuses missing registry secrets before setup and skips private Cargo jobs for fork PRs', () => {
337
520
  const definition = options({ cargoCredentials: { registryTokenEnvs: ['CARGO_REGISTRIES_EXAMPLE_TOKEN'] } });
338
521
  const steps = defineCiWorkflow(definition);
@@ -57,6 +57,35 @@ describe('publish workflow definition', () => {
57
57
  ).resolves.toBe(rendered);
58
58
  });
59
59
 
60
+ it('gives every publish job the declared remote cache, in bytes Prettier keeps', async () => {
61
+ const remoteCache = {
62
+ server: 'https://nx-cache.example.net',
63
+ internalServer: 'http://10.89.0.1:8765',
64
+ tokenSecret: 'NX_REMOTE_CACHE_TOKEN',
65
+ };
66
+ const cacheEnv = {
67
+ NX_SELF_HOSTED_REMOTE_CACHE_SERVER: 'http://10.89.0.1:8765',
68
+ NX_SELF_HOSTED_REMOTE_CACHE_ACCESS_TOKEN: '${{ secrets.NX_REMOTE_CACHE_TOKEN }}',
69
+ };
70
+ const platform = renderPublishWorkflowYaml({ ...codebaseWorkflowOptions, remoteCache });
71
+ const single = renderPublishWorkflowYaml({ repoName: '@smoothbricks/codebase', remoteCache });
72
+
73
+ expect(Bun.YAML.parse(platform)).toMatchObject({
74
+ jobs: {
75
+ 'linux-release-candidate': { env: cacheEnv },
76
+ 'macos-platform': { env: cacheEnv },
77
+ 'publish-on-linux': { env: cacheEnv },
78
+ },
79
+ });
80
+ expect(Bun.YAML.parse(single)).toMatchObject({ jobs: { publish: { env: cacheEnv } } });
81
+ expect(renderPublishWorkflowYaml(codebaseWorkflowOptions)).not.toContain('NX_SELF_HOSTED');
82
+ // A workflow the repository Prettier config would rewrite drifts on the
83
+ // first commit hook, so the cache lines must already be its output.
84
+ await expect(
85
+ format(platform, { parser: 'yaml', printWidth: 120, proseWrap: 'always', singleQuote: true }),
86
+ ).resolves.toBe(platform);
87
+ });
88
+
60
89
  it('passes the projects selector to the version and platform-output steps', async () => {
61
90
  const rendered = renderPublishWorkflowYaml(codebaseWorkflowOptions);
62
91
 
@@ -1,8 +1,9 @@
1
1
  import { describe, expect, it, spyOn } from 'bun:test';
2
+ import { readFileSync } from 'node:fs';
2
3
  import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
4
  import { tmpdir } from 'node:os';
4
5
  import { dirname, join } from 'node:path';
5
- import { validateCargoCachePolicy } from './cargo-policy.js';
6
+ import { applyCargoFeatureUnification, type CargoHakariShell, validateCargoCachePolicy } from './cargo-policy.js';
6
7
 
7
8
  async function withFixture<T>(files: Record<string, string>, callback: (root: string) => T): Promise<T> {
8
9
  const root = await mkdtemp(join(tmpdir(), 'smoo-cargo-policy-'));
@@ -18,17 +19,46 @@ async function withFixture<T>(files: Record<string, string>, callback: (root: st
18
19
  }
19
20
  }
20
21
 
21
- async function check(files: Record<string, string>): Promise<{ failures: number; messages: string[] }> {
22
+ async function check(
23
+ files: Record<string, string>,
24
+ shell?: CargoHakariShell,
25
+ ): Promise<{ failures: number; messages: string[] }> {
22
26
  return withFixture(files, (root) => {
23
27
  const captured = captureErrors();
24
28
  try {
25
- return { failures: validateCargoCachePolicy(root), messages: captured.messages };
29
+ return { failures: validateCargoCachePolicy(root, { shell }), messages: captured.messages };
26
30
  } finally {
27
31
  captured.restore();
28
32
  }
29
33
  });
30
34
  }
31
35
 
36
+ /** Records what update would run, and answers `verify` however the test needs. */
37
+ function recordingHakari(
38
+ verify: { code: number; output?: string; missing?: boolean } = { code: 0 },
39
+ ): CargoHakariShell & {
40
+ calls: string[][];
41
+ } {
42
+ const calls: string[][] = [];
43
+ return {
44
+ calls,
45
+ run(_directory, args) {
46
+ calls.push([...args]);
47
+ return args[0] === 'verify'
48
+ ? { code: verify.code, output: verify.output ?? '', missing: verify.missing ?? false }
49
+ : { code: 0, output: '', missing: false };
50
+ },
51
+ };
52
+ }
53
+
54
+ const NIGHTLY_DEVENV = 'languages.rust = {\n channel = "nightly";\n};\n';
55
+ const UNIFIED_CONFIG = '[unstable]\nfeature-unification = true\n\n[resolver]\nfeature-unification = "workspace"\n';
56
+ const TWO_CRATE_WORKSPACE = {
57
+ 'Cargo.toml': '[workspace]\nmembers = ["crates/*"]\n\n[profile.test]\nincremental = false\ndebug = 0\n',
58
+ 'crates/alpha/Cargo.toml': '[package]\nname = "alpha"\n',
59
+ 'crates/beta/Cargo.toml': '[package]\nname = "beta"\n',
60
+ };
61
+
32
62
  function captureErrors(): { messages: string[]; restore: () => void } {
33
63
  const messages: string[] = [];
34
64
  const error = spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
@@ -186,3 +216,176 @@ describe('Cargo cache policy', () => {
186
216
  expect(result.messages).toEqual([]);
187
217
  });
188
218
  });
219
+
220
+ describe('Cargo workspace feature unification', () => {
221
+ it('leaves a single-crate workspace alone', async () => {
222
+ // Nothing to unify: `feature-unification = "workspace"` and a workspace-hack
223
+ // both exist to stop ONE dependency being built twice with different
224
+ // features for two members, which needs two members.
225
+ const result = await check({
226
+ 'Cargo.toml': '[workspace]\nmembers = ["crates/only"]\n\n[profile.test]\nincremental = false\ndebug = 0\n',
227
+ 'crates/only/Cargo.toml': '[package]\nname = "only"\n',
228
+ 'tooling/direnv/devenv.smoo.nix': NIGHTLY_DEVENV,
229
+ });
230
+ expect(result.failures).toBe(0);
231
+ });
232
+
233
+ it('requires a mechanism once a workspace holds more than one crate', async () => {
234
+ const result = await check({ ...TWO_CRATE_WORKSPACE, 'tooling/direnv/devenv.smoo.nix': NIGHTLY_DEVENV });
235
+ expect(result.failures).toBe(1);
236
+ expect(result.messages[0]).toContain('feature-unification');
237
+ expect(result.messages[0]).toContain('smoo monorepo update');
238
+ });
239
+
240
+ it('accepts the nightly resolver configuration and rejects it without the unstable flag', async () => {
241
+ const configured = await check({
242
+ ...TWO_CRATE_WORKSPACE,
243
+ 'tooling/direnv/devenv.smoo.nix': NIGHTLY_DEVENV,
244
+ '.cargo/config.toml': UNIFIED_CONFIG,
245
+ });
246
+ expect(configured.failures).toBe(0);
247
+
248
+ // Cargo silently ignores [resolver] feature-unification without the
249
+ // unstable opt-in, so the half-configured repository believes it is unified
250
+ // while every crate still re-resolves features.
251
+ const inert = await check({
252
+ ...TWO_CRATE_WORKSPACE,
253
+ 'tooling/direnv/devenv.smoo.nix': NIGHTLY_DEVENV,
254
+ '.cargo/config.toml': '[resolver]\nfeature-unification = "workspace"\n',
255
+ });
256
+ expect(inert.failures).toBe(1);
257
+ expect(inert.messages[0]).toContain('[unstable] feature-unification = true');
258
+ });
259
+
260
+ it('reads the channel from the managed devenv module, not from a decorative rust-toolchain file', async () => {
261
+ // devenv resolves the toolchain through rust-overlay and ignores
262
+ // rust-toolchain.toml unless languages.rust.toolchainFile names it, so the
263
+ // module's channel is the compiler that actually runs these commands.
264
+ const result = await check({
265
+ ...TWO_CRATE_WORKSPACE,
266
+ 'tooling/direnv/devenv.smoo.nix': NIGHTLY_DEVENV,
267
+ 'rust-toolchain.toml': '[toolchain]\nchannel = "stable"\n',
268
+ '.cargo/config.toml': UNIFIED_CONFIG,
269
+ });
270
+ expect(result.failures).toBe(0);
271
+ });
272
+
273
+ it('refuses the nightly-only resolver key on a stable toolchain', async () => {
274
+ const result = await check({
275
+ ...TWO_CRATE_WORKSPACE,
276
+ 'rust-toolchain.toml': '[toolchain]\nchannel = "stable"\n',
277
+ '.cargo/config.toml': UNIFIED_CONFIG,
278
+ });
279
+ expect(result.failures).toBe(1);
280
+ expect(result.messages[0]).toContain('cargo-hakari');
281
+ expect(result.messages[0]).toContain('stable');
282
+ });
283
+
284
+ it('accepts a hakari-managed workspace-hack on a stable toolchain', async () => {
285
+ const hakari = recordingHakari();
286
+ const result = await check(
287
+ {
288
+ 'Cargo.toml':
289
+ '[workspace]\nmembers = ["crates/*", "workspace-hack"]\n\n[profile.test]\nincremental = false\ndebug = 0\n',
290
+ 'crates/alpha/Cargo.toml':
291
+ '[package]\nname = "alpha"\n\n[dependencies]\nworkspace-hack = { path = "../../workspace-hack" }\n',
292
+ 'crates/beta/Cargo.toml':
293
+ '[package]\nname = "beta"\n\n[dependencies]\nworkspace-hack = { path = "../../workspace-hack" }\n',
294
+ 'workspace-hack/Cargo.toml': '[package]\nname = "workspace-hack"\n',
295
+ '.config/hakari.toml': 'hakari-package = "workspace-hack"\nresolver = "2"\n',
296
+ 'rust-toolchain.toml': '[toolchain]\nchannel = "stable"\n',
297
+ },
298
+ hakari,
299
+ );
300
+ expect(result.failures).toBe(0);
301
+ expect(hakari.calls).toEqual([['verify']]);
302
+ });
303
+
304
+ it('flags a crate that does not depend on the workspace-hack', async () => {
305
+ const result = await check(
306
+ {
307
+ 'Cargo.toml':
308
+ '[workspace]\nmembers = ["crates/*", "workspace-hack"]\n\n[profile.test]\nincremental = false\ndebug = 0\n',
309
+ 'crates/alpha/Cargo.toml':
310
+ '[package]\nname = "alpha"\n\n[dependencies]\nworkspace-hack = { path = "../../workspace-hack" }\n',
311
+ 'crates/beta/Cargo.toml': '[package]\nname = "beta"\n',
312
+ 'workspace-hack/Cargo.toml': '[package]\nname = "workspace-hack"\n',
313
+ '.config/hakari.toml': 'hakari-package = "workspace-hack"\n',
314
+ 'rust-toolchain.toml': '[toolchain]\nchannel = "stable"\n',
315
+ },
316
+ recordingHakari(),
317
+ );
318
+ expect(result.failures).toBe(1);
319
+ expect(result.messages[0]).toContain('beta');
320
+ expect(result.messages[0]).toContain('smoo monorepo update');
321
+ });
322
+
323
+ it('surfaces a stale workspace-hack that cargo hakari verify rejects', async () => {
324
+ const result = await check(
325
+ {
326
+ 'Cargo.toml':
327
+ '[workspace]\nmembers = ["crates/*", "workspace-hack"]\n\n[profile.test]\nincremental = false\ndebug = 0\n',
328
+ 'crates/alpha/Cargo.toml':
329
+ '[package]\nname = "alpha"\n\n[dependencies]\nworkspace-hack = { path = "../../workspace-hack" }\n',
330
+ 'crates/beta/Cargo.toml':
331
+ '[package]\nname = "beta"\n\n[dependencies]\nworkspace-hack = { path = "../../workspace-hack" }\n',
332
+ 'workspace-hack/Cargo.toml': '[package]\nname = "workspace-hack"\n',
333
+ '.config/hakari.toml': 'hakari-package = "workspace-hack"\n',
334
+ 'rust-toolchain.toml': '[toolchain]\nchannel = "stable"\n',
335
+ },
336
+ recordingHakari({ code: 1, output: 'workspace-hack is not up-to-date' }),
337
+ );
338
+ expect(result.failures).toBe(1);
339
+ expect(result.messages[0]).toContain('cargo hakari verify');
340
+ expect(result.messages[0]).toContain('workspace-hack is not up-to-date');
341
+ });
342
+ });
343
+
344
+ describe('Cargo feature unification update', () => {
345
+ it('writes the nightly resolver configuration once', async () => {
346
+ await withFixture({ ...TWO_CRATE_WORKSPACE, 'tooling/direnv/devenv.smoo.nix': NIGHTLY_DEVENV }, (root) => {
347
+ const configPath = join(root, '.cargo/config.toml');
348
+ const hakari = recordingHakari();
349
+ applyCargoFeatureUnification(root, { shell: hakari });
350
+ const written = readFileSync(configPath, 'utf8');
351
+ expect(written).toContain('[unstable]\nfeature-unification = true');
352
+ expect(written).toContain('[resolver]\nfeature-unification = "workspace"');
353
+ expect(hakari.calls).toEqual([]);
354
+
355
+ applyCargoFeatureUnification(root, { shell: hakari });
356
+ expect(readFileSync(configPath, 'utf8')).toBe(written);
357
+ const captured = captureErrors();
358
+ try {
359
+ expect(validateCargoCachePolicy(root, { shell: hakari })).toBe(0);
360
+ } finally {
361
+ captured.restore();
362
+ }
363
+ });
364
+ });
365
+
366
+ it('generates and wires a workspace-hack on a stable toolchain', async () => {
367
+ await withFixture(
368
+ { ...TWO_CRATE_WORKSPACE, 'rust-toolchain.toml': '[toolchain]\nchannel = "1.89.0"\n' },
369
+ (root) => {
370
+ const hakari = recordingHakari();
371
+ applyCargoFeatureUnification(root, { shell: hakari });
372
+ expect(hakari.calls).toEqual([['init', 'workspace-hack'], ['generate'], ['manage-deps', '--yes']]);
373
+ },
374
+ );
375
+ });
376
+
377
+ it('skips hakari init when the workspace already declares one', async () => {
378
+ await withFixture(
379
+ {
380
+ ...TWO_CRATE_WORKSPACE,
381
+ 'rust-toolchain.toml': '[toolchain]\nchannel = "1.89.0"\n',
382
+ '.config/hakari.toml': 'hakari-package = "workspace-hack"\n',
383
+ },
384
+ (root) => {
385
+ const hakari = recordingHakari();
386
+ applyCargoFeatureUnification(root, { shell: hakari });
387
+ expect(hakari.calls).toEqual([['generate'], ['manage-deps', '--yes']]);
388
+ },
389
+ );
390
+ });
391
+ });