@smoothbricks/cli 0.11.17 → 0.11.18

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 (52) hide show
  1. package/README.md +22 -4
  2. package/dist/cli.js +10 -7
  3. package/dist/monorepo/ci-workflow.d.ts +56 -0
  4. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  5. package/dist/monorepo/ci-workflow.js +199 -2
  6. package/dist/monorepo/managed-files.d.ts +23 -0
  7. package/dist/monorepo/managed-files.d.ts.map +1 -1
  8. package/dist/monorepo/managed-files.js +39 -1
  9. package/dist/release/github-release.d.ts +10 -0
  10. package/dist/release/github-release.d.ts.map +1 -1
  11. package/dist/release/github-release.js +11 -5
  12. package/dist/release/index.d.ts.map +1 -1
  13. package/dist/release/index.js +4 -5
  14. package/dist/secrets/commands.d.ts +52 -13
  15. package/dist/secrets/commands.d.ts.map +1 -1
  16. package/dist/secrets/commands.js +220 -34
  17. package/dist/secrets/index.d.ts +21 -1
  18. package/dist/secrets/index.d.ts.map +1 -1
  19. package/dist/secrets/index.js +110 -4
  20. package/dist/secrets/repository.d.ts +60 -0
  21. package/dist/secrets/repository.d.ts.map +1 -0
  22. package/dist/secrets/repository.js +145 -0
  23. package/dist/wrangler/cloudflare.d.ts +7 -0
  24. package/dist/wrangler/cloudflare.d.ts.map +1 -1
  25. package/dist/wrangler/cloudflare.js +10 -0
  26. package/dist/wrangler/deploy-stage.d.ts.map +1 -1
  27. package/dist/wrangler/deploy-stage.js +50 -24
  28. package/dist/wrangler/stage-secrets.d.ts +57 -0
  29. package/dist/wrangler/stage-secrets.d.ts.map +1 -0
  30. package/dist/wrangler/stage-secrets.js +178 -0
  31. package/managed/raw/tooling/direnv/devenv.smoo.nix +9 -1
  32. package/managed/raw/tooling/git-hooks/pre-push.sh +30 -29
  33. package/package.json +2 -2
  34. package/src/cli.ts +33 -10
  35. package/src/monorepo/__tests__/ci-workflow.test.ts +161 -0
  36. package/src/monorepo/ci-workflow.ts +277 -2
  37. package/src/monorepo/managed-files.test.ts +27 -0
  38. package/src/monorepo/managed-files.ts +56 -2
  39. package/src/monorepo/package-policy.test.ts +1 -1
  40. package/src/release/__tests__/github-release.test.ts +8 -4
  41. package/src/release/github-release.ts +13 -5
  42. package/src/release/index.ts +4 -4
  43. package/src/secrets/commands.test.ts +28 -0
  44. package/src/secrets/commands.ts +237 -35
  45. package/src/secrets/index.test.ts +118 -1
  46. package/src/secrets/index.ts +108 -4
  47. package/src/secrets/repository.test.ts +98 -0
  48. package/src/secrets/repository.ts +164 -0
  49. package/src/wrangler/cloudflare.ts +18 -0
  50. package/src/wrangler/deploy-stage.test.ts +258 -11
  51. package/src/wrangler/deploy-stage.ts +70 -38
  52. package/src/wrangler/stage-secrets.ts +146 -0
@@ -0,0 +1,57 @@
1
+ import { type DeploymentStage } from './stage.js';
2
+ /**
3
+ * How a declaration names a stage. The fixed stages go by their own name; every `prN` stage is
4
+ * `preview`, because a scope is written once and pull-request numbers are not knowable in advance.
5
+ */
6
+ export type SecretStageScope = 'staging' | 'production' | 'preview';
7
+ /**
8
+ * `smoo.wrangler.secretStages`: a declared secret name mapped to the stages it belongs to. The map
9
+ * answers two questions with one declaration, and both directions matter:
10
+ *
11
+ * - requirement — a stage the secret belongs to refuses to deploy without a value for it;
12
+ * - permission — a stage the secret does *not* belong to never receives it, however loudly the
13
+ * deploying shell exports it. A test-only capability exported by CI for preview stages must not
14
+ * ride along into production just because the variable happens to be set.
15
+ *
16
+ * A declared name absent from the map belongs to every stage. A name mapped to `[]` belongs to no
17
+ * stage, which is how a value that exists only for local development is declared.
18
+ */
19
+ export type SecretStageMap = Record<string, SecretStageScope[]>;
20
+ /** Secret NAMES the project declares. Values live on the Worker; the repo only ever holds the keys. */
21
+ export declare function readDeclaredSecretNames(cwd: string): string[];
22
+ /** `smoo.wrangler.secretStages` from the project's package.json; no file and no block scope nothing. */
23
+ export declare function readSecretStageMap(cwd: string): SecretStageMap;
24
+ /** Which of a project's declared secrets one stage may see, and which belong to other stages. */
25
+ export interface StageSecretPlan {
26
+ stage: DeploymentStage;
27
+ /** Declared names this stage requires — and the only ones a deploy of it may carry. */
28
+ required: string[];
29
+ /** Declared names scoped to other stages: withheld from this deploy even when a value is exported. */
30
+ withheld: string[];
31
+ /** The declaration itself, so a refusal can say why each name is where it is. */
32
+ scopes: SecretStageMap;
33
+ }
34
+ /**
35
+ * Splits the declared secrets by whether `stage` is in scope for each.
36
+ *
37
+ * A scope on an undeclared name is refused rather than ignored: it is almost always a typo of a
38
+ * real secret's name, and its effect is the dangerous direction — the misspelt entry scopes
39
+ * nothing while the real secret, still absent from the map, reaches every stage.
40
+ */
41
+ export declare function planStageSecrets(declared: string[], stage: DeploymentStage, scopes: SecretStageMap): StageSecretPlan;
42
+ /**
43
+ * Why this deploy must not proceed, or nothing when it may.
44
+ *
45
+ * Two refusals, reported together so one run names every problem:
46
+ *
47
+ * - a required secret with no value anywhere. `--secrets-file` applies additively, so a deploy
48
+ * that never mentions a secret leaves whatever the Worker already holds — silence that reads as
49
+ * success while a secret introduced after the first deploy never arrives, and the code that
50
+ * needs it fails at runtime instead of here.
51
+ * - a withheld secret the Worker already holds. Filtering it out of this deploy's payload cannot
52
+ * remove it, so the scope would be nominal rather than enforced until someone deletes it.
53
+ *
54
+ * A value is never read, never formatted, and never named beyond its key.
55
+ */
56
+ export declare function stageSecretRefusal(plan: StageSecretPlan, exported: ReadonlySet<string>, held: ReadonlySet<string>, workerName: string): string | undefined;
57
+ //# sourceMappingURL=stage-secrets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stage-secrets.d.ts","sourceRoot":"","sources":["../../src/wrangler/stage-secrets.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,KAAK,eAAe,EAAsB,MAAM,YAAY,CAAC;AAEtE;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,YAAY,GAAG,SAAS,CAAC;AAEpE;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,gBAAgB,EAAE,CAAC,CAAC;AAahE,uGAAuG;AACvG,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAG7D;AAED,wGAAwG;AACxG,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,CAQ9D;AAED,iGAAiG;AACjG,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,eAAe,CAAC;IACvB,uFAAuF;IACvF,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,sGAAsG;IACtG,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,iFAAiF;IACjF,MAAM,EAAE,cAAc,CAAC;CACxB;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,KAAK,EAAE,eAAe,EAAE,MAAM,EAAE,cAAc,GAAG,eAAe,CAsBpH;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,eAAe,EACrB,QAAQ,EAAE,WAAW,CAAC,MAAM,CAAC,EAC7B,IAAI,EAAE,WAAW,CAAC,MAAM,CAAC,EACzB,UAAU,EAAE,MAAM,GACjB,MAAM,GAAG,SAAS,CAsBpB"}
@@ -0,0 +1,178 @@
1
+ import * as _accessExpressionAsString_1 from "typia/lib/internal/_accessExpressionAsString";
2
+ const __typia_transform__accessExpressionAsString = _accessExpressionAsString_1._accessExpressionAsString;
3
+ import * as _validateReport_1 from "typia/lib/internal/_validateReport";
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import typia from 'typia';
7
+ import { formatValidationErrors, parseJsonFileText } from '../lib/json.js';
8
+ import { parseDevVarsExample } from './prepare-env.js';
9
+ import { isPullRequestStage } from './stage.js';
10
+ const validateWranglerManifest = (() => {
11
+ const _io0 = input => undefined === input.smoo || "object" === typeof input.smoo && null !== input.smoo && false === Array.isArray(input.smoo) && _io1(input.smoo);
12
+ const _io1 = input => undefined === input.wrangler || "object" === typeof input.wrangler && null !== input.wrangler && false === Array.isArray(input.wrangler) && _io2(input.wrangler);
13
+ const _io2 = input => undefined === input.secretStages || "object" === typeof input.secretStages && null !== input.secretStages && false === Array.isArray(input.secretStages) && _io3(input.secretStages);
14
+ const _io3 = input => Object.keys(input).every(key => {
15
+ const value = input[key];
16
+ if (undefined === value)
17
+ return true;
18
+ return Array.isArray(value) && value.every(elem => "preview" === elem || "production" === elem || "staging" === elem);
19
+ });
20
+ const _vo0 = (input, _path, _exceptionable = true) => [undefined === input.smoo || ("object" === typeof input.smoo && null !== input.smoo && false === Array.isArray(input.smoo) || _report(_exceptionable, {
21
+ path: _path + ".smoo",
22
+ expected: "(undefined | { wrangler?: { secretStages?: SecretStageMap | undefined; } | undefined; })",
23
+ value: input.smoo
24
+ })) && _vo1(input.smoo, _path + ".smoo", true && _exceptionable) || _report(_exceptionable, {
25
+ path: _path + ".smoo",
26
+ expected: "(undefined | { wrangler?: { secretStages?: SecretStageMap | undefined; } | undefined; })",
27
+ value: input.smoo
28
+ })].every(flag => flag);
29
+ const _vo1 = (input, _path, _exceptionable = true) => [undefined === input.wrangler || ("object" === typeof input.wrangler && null !== input.wrangler && false === Array.isArray(input.wrangler) || _report(_exceptionable, {
30
+ path: _path + ".wrangler",
31
+ expected: "(undefined | { secretStages?: SecretStageMap | undefined; })",
32
+ value: input.wrangler
33
+ })) && _vo2(input.wrangler, _path + ".wrangler", true && _exceptionable) || _report(_exceptionable, {
34
+ path: _path + ".wrangler",
35
+ expected: "(undefined | { secretStages?: SecretStageMap | undefined; })",
36
+ value: input.wrangler
37
+ })].every(flag => flag);
38
+ const _vo2 = (input, _path, _exceptionable = true) => [undefined === input.secretStages || ("object" === typeof input.secretStages && null !== input.secretStages && false === Array.isArray(input.secretStages) || _report(_exceptionable, {
39
+ path: _path + ".secretStages",
40
+ expected: "(SecretStageMap | undefined)",
41
+ value: input.secretStages
42
+ })) && _vo3(input.secretStages, _path + ".secretStages", true && _exceptionable) || _report(_exceptionable, {
43
+ path: _path + ".secretStages",
44
+ expected: "(SecretStageMap | undefined)",
45
+ value: input.secretStages
46
+ })].every(flag => flag);
47
+ const _vo3 = (input, _path, _exceptionable = true) => [false === _exceptionable || Object.keys(input).map(key => {
48
+ const value = input[key];
49
+ if (undefined === value)
50
+ return true;
51
+ return (Array.isArray(value) || _report(_exceptionable, {
52
+ path: _path + __typia_transform__accessExpressionAsString(key),
53
+ expected: "Array<SecretStageScope>",
54
+ value: value
55
+ })) && value.map((elem, _index2) => "preview" === elem || "production" === elem || "staging" === elem || _report(_exceptionable, {
56
+ path: _path + __typia_transform__accessExpressionAsString(key) + "[" + _index2 + "]",
57
+ expected: "(\"preview\" | \"production\" | \"staging\")",
58
+ value: elem
59
+ })).every(flag => flag) || _report(_exceptionable, {
60
+ path: _path + __typia_transform__accessExpressionAsString(key),
61
+ expected: "Array<SecretStageScope>",
62
+ value: value
63
+ });
64
+ }).every(flag => flag)].every(flag => flag);
65
+ const __is = input => "object" === typeof input && null !== input && false === Array.isArray(input) && _io0(input);
66
+ let errors;
67
+ let _report;
68
+ const __validate = input => {
69
+ if (false === __is(input)) {
70
+ errors = [];
71
+ _report = _validateReport_1._validateReport(errors);
72
+ ((input, _path, _exceptionable = true) => ("object" === typeof input && null !== input && false === Array.isArray(input) || _report(true, {
73
+ path: _path + "",
74
+ expected: "WranglerPackageManifest",
75
+ value: input
76
+ })) && _vo0(input, _path + "", true) || _report(true, {
77
+ path: _path + "",
78
+ expected: "WranglerPackageManifest",
79
+ value: input
80
+ }))(input, "$input", true);
81
+ const success = 0 === errors.length;
82
+ return success ? {
83
+ success,
84
+ data: input
85
+ } : {
86
+ success,
87
+ errors,
88
+ data: input
89
+ };
90
+ }
91
+ return {
92
+ success: true,
93
+ data: input
94
+ };
95
+ };
96
+ return input => __validate(JSON.parse(input));
97
+ })();
98
+ /** Secret NAMES the project declares. Values live on the Worker; the repo only ever holds the keys. */
99
+ export function readDeclaredSecretNames(cwd) {
100
+ const path = join(cwd, '.dev.vars.example');
101
+ return existsSync(path) ? parseDevVarsExample(readFileSync(path, 'utf8')) : [];
102
+ }
103
+ /** `smoo.wrangler.secretStages` from the project's package.json; no file and no block scope nothing. */
104
+ export function readSecretStageMap(cwd) {
105
+ const path = join(cwd, 'package.json');
106
+ if (!existsSync(path))
107
+ return {};
108
+ const result = parseJsonFileText(path, readFileSync(path, 'utf8'), validateWranglerManifest);
109
+ if (!result.success) {
110
+ throw new Error(`${path} declares an invalid smoo.wrangler block: ${formatValidationErrors(result.errors)}`);
111
+ }
112
+ return result.data.smoo?.wrangler?.secretStages ?? {};
113
+ }
114
+ /**
115
+ * Splits the declared secrets by whether `stage` is in scope for each.
116
+ *
117
+ * A scope on an undeclared name is refused rather than ignored: it is almost always a typo of a
118
+ * real secret's name, and its effect is the dangerous direction — the misspelt entry scopes
119
+ * nothing while the real secret, still absent from the map, reaches every stage.
120
+ */
121
+ export function planStageSecrets(declared, stage, scopes) {
122
+ const declaredNames = new Set(declared);
123
+ const undeclared = Object.keys(scopes).filter((name) => !declaredNames.has(name));
124
+ if (undeclared.length > 0) {
125
+ throw new Error(`smoo.wrangler.secretStages scopes ${undeclared.join(', ')}, which .dev.vars.example does not declare. ` +
126
+ 'A scope on a name no secret has leaves the secret it was meant for unscoped, so that secret reaches every stage.');
127
+ }
128
+ // Every `prN` stage answers to one written token: a scope cannot name pull requests in advance.
129
+ const scope = isPullRequestStage(stage) ? 'preview' : stage;
130
+ const required = [];
131
+ const withheld = [];
132
+ for (const name of declared) {
133
+ const declaredScope = scopes[name];
134
+ if (declaredScope === undefined || declaredScope.includes(scope)) {
135
+ required.push(name);
136
+ }
137
+ else {
138
+ withheld.push(name);
139
+ }
140
+ }
141
+ return { stage, required, withheld, scopes };
142
+ }
143
+ /**
144
+ * Why this deploy must not proceed, or nothing when it may.
145
+ *
146
+ * Two refusals, reported together so one run names every problem:
147
+ *
148
+ * - a required secret with no value anywhere. `--secrets-file` applies additively, so a deploy
149
+ * that never mentions a secret leaves whatever the Worker already holds — silence that reads as
150
+ * success while a secret introduced after the first deploy never arrives, and the code that
151
+ * needs it fails at runtime instead of here.
152
+ * - a withheld secret the Worker already holds. Filtering it out of this deploy's payload cannot
153
+ * remove it, so the scope would be nominal rather than enforced until someone deletes it.
154
+ *
155
+ * A value is never read, never formatted, and never named beyond its key.
156
+ */
157
+ export function stageSecretRefusal(plan, exported, held, workerName) {
158
+ const unavailable = plan.required.filter((name) => !exported.has(name) && !held.has(name));
159
+ const installed = plan.withheld.filter((name) => held.has(name));
160
+ if (unavailable.length === 0 && installed.length === 0)
161
+ return undefined;
162
+ const lines = [`Refusing to deploy ${workerName} to ${plan.stage}.`];
163
+ if (unavailable.length > 0) {
164
+ lines.push(`Stage ${plan.stage} requires these secrets and no value exists for them, neither in this environment nor on the Worker:`, ...unavailable.map((name) => ` ${name} — ${scopeDescription(name, plan.scopes)}`), 'Export each one in the deploying shell before the deploy. Once the Worker exists,', `\`wrangler secret put <NAME> --name ${workerName}\` supplies it too.`);
165
+ }
166
+ if (installed.length > 0) {
167
+ lines.push(`The Worker holds these secrets, which smoo.wrangler.secretStages keeps out of ${plan.stage}:`, ...installed.map((name) => ` ${name} — ${scopeDescription(name, plan.scopes)}`), `Delete each one with \`wrangler secret delete <NAME> --name ${workerName}\`. A deploy of this stage`, 'never sends them, so leaving them installed would keep the scope nominal.');
168
+ }
169
+ return lines.join('\n');
170
+ }
171
+ function scopeDescription(name, scopes) {
172
+ const scope = scopes[name];
173
+ if (scope === undefined)
174
+ return 'unscoped, so every stage requires it';
175
+ if (scope.length === 0)
176
+ return 'scoped to no stage (local development only)';
177
+ return `scoped to ${scope.join(', ')}`;
178
+ }
@@ -354,7 +354,15 @@
354
354
  export GOFLAGS="''${GOFLAGS:--trimpath}"
355
355
  unset GOROOT
356
356
  bun "$DEVENV_ROOT/setup-environment.ts" || exit $?
357
- export NX_SOCKET_DIR="$DEVENV_RUNTIME/nx"
357
+ # One socket dir per Nx workspace. DEVENV_RUNTIME is keyed to the devenv
358
+ # ROOT, so every workspace sharing one devenv - a sibling repository, a
359
+ # copy-on-write clone, a scratch workspace created inside this shell -
360
+ # would land on one socket. The first daemon to claim it then refuses
361
+ # every message from the others ("received a message from a different
362
+ # workspace"), which reads as a hung Nx in a workspace that did nothing
363
+ # wrong. Nx's own diagnostic names this exact cause.
364
+ nx_workspace_root="$(cd "$DEVENV_ROOT/../.." >/dev/null 2>&1 && pwd || printf '%s' "$PWD")"
365
+ export NX_SOCKET_DIR="$DEVENV_RUNTIME/nx-$(printf '%s' "$nx_workspace_root" | cksum | cut -d' ' -f1)"
358
366
  mkdir -p "$NX_SOCKET_DIR"
359
367
  ${lib.optionalString pkgs.stdenv.isDarwin ''
360
368
  unset CC CXX
@@ -1,25 +1,25 @@
1
1
  #!/usr/bin/env bash
2
- # macOS-only Linux compile gate. Everything in tooling depends only on
3
- # devenv.nix packages, so this hook must also work without devenv on PATH
4
- # but it may not pretend a check ran that never did. Nx task hashes can
5
- # differ between the bare shell and the linux-cross profile, so a bare miss
6
- # proves nothing on its own. Policy, in order:
7
- # 1. Bare `nx run-many -t cargo-lint-cross`: a hit is a prior real
8
- # `cargo clippy --target x86_64-unknown-linux-gnu`; pass with no toolchain.
9
- # 2. If a Nix-built devenv is on PATH, `bun run check:linux` enters the
10
- # linux-cross profile properly and its result decides the push.
11
- # 3. Otherwise refuse with the recovery command instead of a toolchain error
12
-
13
- # A Nix-built devenv binary (under /nix/store), skipping the repo wrapper
14
- # scripts that also answer to this name but cannot enter a profile alone.
15
- has_nix_devenv() {
16
- while IFS= read -r candidate; do
17
- case "$(realpath "$candidate" 2>/dev/null)" in
18
- /nix/store/*) return 0 ;;
19
- esac
20
- done < <(which -a devenv 2>/dev/null)
21
- return 1
22
- }
2
+ # macOS-only Linux cross-compile gate. This hook is a cache PROBE and nothing
3
+ # else: it reads the nx cache and never compiles. A hit means a real
4
+ # `cargo clippy --target x86_64-unknown-linux-gnu` already passed for exactly
5
+ # this tree, so the push is safe with no toolchain present. Anything else
6
+ # refuses the push and names the one command that fixes it.
7
+ #
8
+ # Why probe-only. The cross-clippy needs the linux-cross C toolchain, which
9
+ # lives in a devenv profile. A hook that entered that profile spent minutes
10
+ # compiling on a push the user expected to take a second, and it did so in an
11
+ # environment they never asked for. Pushing is not the place to discover that
12
+ # the tree has not been compiled for Linux; `bun run check:linux` is.
13
+ #
14
+ # CC_x86_64_unknown_linux_gnu is unset for the probe deliberately. The target's
15
+ # own guard reads it to decide whether the toolchain is present, so leaving it
16
+ # set would let a push made from inside an already-entered linux-cross shell
17
+ # fall through into a real multi-minute compile. It is not a declared input of
18
+ # the target, so unsetting it cannot change the task hash — a warm entry still
19
+ # hits.
20
+ #
21
+ # Everything in tooling depends only on devenv.nix packages, so this hook also
22
+ # works with no devenv on PATH. It must never pretend a check ran that did not.
23
23
 
24
24
  cd "$(git rev-parse --show-toplevel)"
25
25
  TOOLING="$PWD/tooling"
@@ -31,17 +31,18 @@ case "$(uname -s)" in
31
31
  *) exit 0 ;;
32
32
  esac
33
33
 
34
- if nx run-many -t cargo-lint-cross; then
34
+ if env -u CC_x86_64_unknown_linux_gnu nx run-many -t cargo-lint-cross --output-style=static; then
35
35
  exit 0
36
36
  fi
37
37
 
38
- if has_nix_devenv; then
39
- exec bun run check:linux
40
- fi
41
-
42
38
  cat >&2 <<'EOF'
43
- pre-push: cargo-lint-cross is not cached for this tree and no Nix-built
44
- devenv is on PATH to enter the linux-cross profile. Run `bun run check:linux`
45
- from a shell with nix on PATH, wait for it to pass, then push again.
39
+
40
+ pre-push: the Linux cross-compile check is NOT cached for this tree, so this
41
+ push would ship code that has never been compiled for Linux. This hook only
42
+ reads the cache; it does not build. The "needs the linux-cross C toolchain"
43
+ line above is the probe refusing to compile, not a broken toolchain.
44
+
45
+ Run: bun run check:linux
46
+ Wait for it to pass, then push again.
46
47
  EOF
47
48
  exit 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smoothbricks/cli",
3
- "version": "0.11.17",
3
+ "version": "0.11.18",
4
4
  "type": "module",
5
5
  "description": "SmoothBricks monorepo automation CLI",
6
6
  "bin": {
@@ -64,7 +64,7 @@
64
64
  ],
65
65
  "dependencies": {
66
66
  "@arethetypeswrong/core": "^0.18.2",
67
- "@smoothbricks/nx-plugin": "0.4.11",
67
+ "@smoothbricks/nx-plugin": "0.4.12",
68
68
  "@smoothbricks/validation": "0.1.8",
69
69
  "commander": "^14.0.3",
70
70
  "make-synchronized": "^0.8.0",
package/src/cli.ts CHANGED
@@ -532,23 +532,46 @@ function buildProgram(): Command {
532
532
  .description('Reconcile declared secrets: what Workers need, what workflows pass, what the repository holds');
533
533
  secrets
534
534
  .command('status')
535
- .description('Show every declared secret and refuse when a workflow passes one the repository lacks')
536
- .option('--repo <owner/name>', 'repository to read secrets from; defaults to the current checkout')
537
- .action(async (options: { repo?: string }) => {
535
+ .description(
536
+ 'Show every declared secret with the scopes holding it, and refuse when a workflow passes one none has',
537
+ )
538
+ .option(
539
+ '-R, --repo <owner/name|remote>',
540
+ "repository or remote name; defaults to the current branch's upstream remote",
541
+ )
542
+ .option(
543
+ '--env <environment>',
544
+ "also read this GitHub Environment; its value takes precedence over the repository's for a job bound to it",
545
+ )
546
+ .action(async (options: { repo?: string; env?: string }) => {
538
547
  process.exitCode = secretsStatus(await findRepoRoot(), options);
539
548
  });
540
549
  secrets
541
- .command('set <name>')
542
- .description('Set one repository secret from a pasted value; the value is read without echo and never logged')
543
- .option('--repo <owner/name>', 'repository to set the secret on')
544
- .action(async (name: string, options: { repo?: string }) => {
545
- process.exitCode = await secretsSet(name, options);
550
+ .command('set [name]')
551
+ .description('Set secrets from pasted values; with no name, prompts for every secret the target scope still lacks')
552
+ .option(
553
+ '-R, --repo <owner/name|remote>',
554
+ "repository or remote name; defaults to the current branch's upstream remote",
555
+ )
556
+ .option(
557
+ '--env <environment>',
558
+ "write into this GitHub Environment; its value takes precedence over the repository's for a job bound to it",
559
+ )
560
+ .action(async (name: string | undefined, options: { repo?: string; env?: string }) => {
561
+ process.exitCode = await secretsSet(await findRepoRoot(), name, options);
546
562
  });
547
563
  secrets
548
564
  .command('sync')
549
565
  .description('Push every secret smoo.secrets can fetch locally to the repository')
550
- .option('--repo <owner/name>', 'repository to set the secrets on')
551
- .action(async (options: { repo?: string }) => {
566
+ .option(
567
+ '-R, --repo <owner/name|remote>',
568
+ "repository or remote name; defaults to the current branch's upstream remote",
569
+ )
570
+ .option(
571
+ '--env <environment>',
572
+ "write into this GitHub Environment; its value takes precedence over the repository's for a job bound to it",
573
+ )
574
+ .action(async (options: { repo?: string; env?: string }) => {
552
575
  process.exitCode = await secretsSync(await findRepoRoot(), options);
553
576
  });
554
577
 
@@ -7,6 +7,7 @@ import { readFile } from 'node:fs/promises';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
9
  import { format } from 'prettier';
10
+ import typia from 'typia';
10
11
  import type { PackageCargoGitOrigin } from '../../lib/json.js';
11
12
  import {
12
13
  type CiWorkflowDefinitionOptions,
@@ -898,3 +899,163 @@ describe('renderCiWorkflowYaml with deploy configuration', () => {
898
899
  expect(e2eJob).not.toContain('CARGO_REGISTRIES_EXAMPLE_TOKEN');
899
900
  });
900
901
  });
902
+
903
+ describe('renderCiWorkflowYaml with cross-built test archives', () => {
904
+ const darwin = {
905
+ triple: 'aarch64-apple-darwin',
906
+ path: 'target/nextest/archive-aarch64-apple-darwin.tar.zst',
907
+ };
908
+ const declared = options({
909
+ runsOn: [...nixosRunsOn],
910
+ macosRunsOn: ['macos-arm64', 'self-hosted'],
911
+ crossTestArchives: [darwin],
912
+ });
913
+
914
+ it('renders nothing for a repository that declares no cross archives', () => {
915
+ const bare = renderCiWorkflowYaml(options({ runsOn: [...nixosRunsOn] }));
916
+
917
+ // An empty declaration is the same repository as an absent one: no step, no
918
+ // job, and above all no renumbering of the steps that were already there.
919
+ expect(renderCiWorkflowYaml(options({ runsOn: [...nixosRunsOn], crossTestArchives: [] }))).toBe(bare);
920
+ expect(bare).not.toContain('macos-cross-tests');
921
+ expect(bare).not.toContain('cross-target test archives');
922
+ expect(bare).not.toContain('cargo-cross-test');
923
+ });
924
+
925
+ it('builds and uploads each archive in Validate, then executes it in a job that needs Validate', () => {
926
+ const rendered = renderCiWorkflowYaml(declared);
927
+ const workflow = typia.assert<{
928
+ jobs: Record<string, { needs?: string; 'runs-on'?: unknown; 'timeout-minutes'?: number; steps: unknown[] }>;
929
+ }>(Bun.YAML.parse(rendered));
930
+ const validate = workflow.jobs.main;
931
+ const execute = workflow.jobs['macos-cross-tests'];
932
+
933
+ expect(execute?.needs).toBe('main');
934
+ expect(execute?.['runs-on']).toEqual(['macos-arm64', 'self-hosted']);
935
+ expect(execute?.['timeout-minutes']).toBe(30);
936
+ // The Linux job COMPILES the darwin binaries; that is what proves the cross
937
+ // build works at all.
938
+ expect(rendered).toContain(
939
+ 'run: smoo github-ci nx-run-many --targets "cargo-cross-test-archive-aarch64-apple-darwin"',
940
+ );
941
+ expect(JSON.stringify(validate?.steps)).toContain('cross-test-archives-${{ github.run_id }}');
942
+ // ...and the macOS job only RUNS them: every step is checkout, the shell,
943
+ // the download, the archive run, or the cache save. Nothing invokes cargo,
944
+ // a toolchain install, or an SDK.
945
+ const executeSteps = execute?.steps ?? [];
946
+ expect(executeSteps).toContainEqual({
947
+ name: '🧪 Cross-Target Unit Tests',
948
+ run: 'smoo github-ci nx-run-many --targets "cargo-cross-test-aarch64-apple-darwin"',
949
+ });
950
+ const executeText = JSON.stringify(executeSteps);
951
+ expect(executeText).toContain('cross-test-archives-${{ github.run_id }}');
952
+ expect(executeText).toContain('actions/download-artifact');
953
+ expect(executeText).not.toContain('cargo ');
954
+ expect(executeText).not.toContain('rustup');
955
+ expect(executeText).not.toContain('cargo-cross-test-archive');
956
+ expect(executeText).not.toContain('--target build');
957
+ expect(executeText).not.toContain('SDK');
958
+ });
959
+
960
+ it('reuses the declared cross producer for the archive step, step-scoped', () => {
961
+ const rendered = renderCiWorkflowYaml(
962
+ options({
963
+ ...declared,
964
+ platformProducer: {
965
+ kind: 'linux-cross',
966
+ preflight: 'sh scripts/prepare-macos-sdk.sh',
967
+ env: { ACME_CROSS: '1', SDKROOT: '${{ runner.temp }}/apple-sdk/MacOSX.sdk' },
968
+ },
969
+ }),
970
+ );
971
+ const validate = rendered.slice(0, rendered.indexOf(' macos-cross-tests:'));
972
+
973
+ expect(validate).toContain('- name: Check cross-platform toolchain prerequisites');
974
+ expect(validate).toContain(' working-directory: .\n');
975
+ expect(validate).toContain(' set -euo pipefail\n sh scripts/prepare-macos-sdk.sh');
976
+ // Step-scoped, so Validate's host builds stay host builds: the pair appears
977
+ // once per cross step and never in the job's own env block.
978
+ expect(validate.match(/ACME_CROSS: "1"/g)).toHaveLength(2);
979
+ const jobEnv = validate.slice(validate.indexOf(' env:'), validate.indexOf(' steps:'));
980
+ expect(jobEnv).not.toContain('ACME_CROSS');
981
+ expect(jobEnv).not.toContain('SDKROOT');
982
+ // A declared producer with no preflight is a mechanism error at render time.
983
+ expect(() =>
984
+ renderCiWorkflowYaml(options({ ...declared, platformProducer: { kind: 'linux-cross', preflight: ' ' } })),
985
+ ).toThrow('nonempty toolchain preflight');
986
+ });
987
+
988
+ it('numbers the execute job download before the run and keeps the cleanup anchor after both', () => {
989
+ const execute = crossTestJob(renderCiWorkflowYaml(declared));
990
+
991
+ // Checkout 2, setup-devenv 3, download 4, run 5, cleanup 6.
992
+ expect(execute).toContain('# Step 4\n - name: 📥 Download cross-target test archives');
993
+ expect(execute).toContain('# Step 5\n - name: 🧪 Cross-Target Unit Tests');
994
+ expect(execute).toContain('# Step 6');
995
+ expect(execute).toContain('uses: ./.github/actions/save-nix-devenv');
996
+ });
997
+
998
+ it('builds a non-darwin triple without an execution job it has no runner for', () => {
999
+ const rendered = renderCiWorkflowYaml(
1000
+ options({
1001
+ crossTestArchives: [
1002
+ { triple: 'x86_64-unknown-linux-musl', path: 'target/nextest/archive-x86_64-unknown-linux-musl.tar.zst' },
1003
+ ],
1004
+ }),
1005
+ );
1006
+
1007
+ expect(rendered).toContain(
1008
+ 'run: smoo github-ci nx-run-many --targets "cargo-cross-test-archive-x86_64-unknown-linux-musl"',
1009
+ );
1010
+ expect(rendered).not.toContain('macos-cross-tests');
1011
+ });
1012
+
1013
+ it('downloads a nested cargo workspace archive back to the directory its target writes', () => {
1014
+ const execute = crossTestJob(
1015
+ renderCiWorkflowYaml(
1016
+ options({
1017
+ crossTestArchives: [{ ...darwin, path: `packages/ferris/${darwin.path}` }],
1018
+ }),
1019
+ ),
1020
+ );
1021
+
1022
+ expect(execute).toContain('path: packages/ferris/target/nextest');
1023
+ });
1024
+
1025
+ it('refuses declarations the Nx graph could not have produced, at render time', () => {
1026
+ expect(() =>
1027
+ renderCiWorkflowYaml(options({ crossTestArchives: [{ ...darwin, path: 'target/nextest/archive.tar.zst' }] })),
1028
+ ).toThrow('must be a repository-relative target/nextest/archive-aarch64-apple-darwin.tar.zst');
1029
+ expect(() =>
1030
+ renderCiWorkflowYaml(options({ crossTestArchives: [{ ...darwin, path: `../${darwin.path}` }] })),
1031
+ ).toThrow('must stay inside the repository');
1032
+ expect(() =>
1033
+ renderCiWorkflowYaml(
1034
+ options({ crossTestArchives: [{ triple: 'Bad Triple; rm -rf /', path: 'target/nextest/x.tar.zst' }] }),
1035
+ ),
1036
+ ).toThrow('must be a target triple');
1037
+ // Two cargo workspaces cannot share one artifact: upload-artifact roots it
1038
+ // at the least common ancestor, so the restored paths would be wrong for
1039
+ // both. Refuse here rather than on the runner.
1040
+ expect(() =>
1041
+ renderCiWorkflowYaml(
1042
+ options({
1043
+ crossTestArchives: [
1044
+ darwin,
1045
+ { triple: 'x86_64-apple-darwin', path: `packages/ferris/${cargoArchive('x86_64-apple-darwin')}` },
1046
+ ],
1047
+ }),
1048
+ ),
1049
+ ).toThrow('must share one directory');
1050
+ });
1051
+ });
1052
+
1053
+ function crossTestJob(rendered: string): string {
1054
+ const start = rendered.indexOf(' macos-cross-tests:');
1055
+ expect(start).toBeGreaterThan(-1);
1056
+ return rendered.slice(start);
1057
+ }
1058
+
1059
+ function cargoArchive(triple: string): string {
1060
+ return `target/nextest/archive-${triple}.tar.zst`;
1061
+ }