@smoothbricks/cli 0.11.15 → 0.11.17

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 (70) hide show
  1. package/README.md +43 -0
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +44 -0
  4. package/dist/github-ci/index.d.ts +1 -1
  5. package/dist/github-ci/index.d.ts.map +1 -1
  6. package/dist/github-ci/index.js +7 -10
  7. package/dist/lib/json.d.ts +9 -0
  8. package/dist/lib/json.d.ts.map +1 -1
  9. package/dist/lib/json.js +65 -53
  10. package/dist/lib/secret-names.d.ts +35 -0
  11. package/dist/lib/secret-names.d.ts.map +1 -0
  12. package/dist/lib/secret-names.js +61 -0
  13. package/dist/monorepo/ci-workflow.d.ts.map +1 -1
  14. package/dist/monorepo/ci-workflow.js +15 -6
  15. package/dist/monorepo/managed-files.d.ts +9 -0
  16. package/dist/monorepo/managed-files.d.ts.map +1 -1
  17. package/dist/monorepo/managed-files.js +28 -2
  18. package/dist/monorepo/publish-workflow.d.ts +10 -1
  19. package/dist/monorepo/publish-workflow.d.ts.map +1 -1
  20. package/dist/monorepo/publish-workflow.js +110 -22
  21. package/dist/release/index.d.ts +1 -1
  22. package/dist/release/index.d.ts.map +1 -1
  23. package/dist/release/index.js +26 -4
  24. package/dist/release/private-npm.d.ts +5 -0
  25. package/dist/release/private-npm.d.ts.map +1 -1
  26. package/dist/release/private-npm.js +26 -5
  27. package/dist/secrets/commands.d.ts +45 -0
  28. package/dist/secrets/commands.d.ts.map +1 -0
  29. package/dist/secrets/commands.js +189 -0
  30. package/dist/secrets/index.d.ts +83 -0
  31. package/dist/secrets/index.d.ts.map +1 -0
  32. package/dist/secrets/index.js +149 -0
  33. package/dist/wrangler/deploy-stage.d.ts +13 -2
  34. package/dist/wrangler/deploy-stage.d.ts.map +1 -1
  35. package/dist/wrangler/deploy-stage.js +39 -54
  36. package/dist/wrangler/deployed-version.d.ts +44 -0
  37. package/dist/wrangler/deployed-version.d.ts.map +1 -0
  38. package/dist/wrangler/deployed-version.js +87 -0
  39. package/dist/wrangler/live-version.d.ts +146 -0
  40. package/dist/wrangler/live-version.d.ts.map +1 -0
  41. package/dist/wrangler/live-version.js +326 -0
  42. package/managed/raw/tooling/direnv/devenv.smoo.nix +4 -2
  43. package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +4 -1
  44. package/managed/raw/tooling/direnv/toolchain-stamp.ts +88 -0
  45. package/managed/templates/github/actions/setup-devenv/action.yml +1 -1
  46. package/package.json +2 -2
  47. package/src/cli.ts +46 -1
  48. package/src/github-ci/index.test.ts +92 -8
  49. package/src/github-ci/index.ts +7 -10
  50. package/src/lib/json.ts +9 -0
  51. package/src/lib/secret-names.ts +70 -0
  52. package/src/monorepo/__tests__/ci-workflow.test.ts +27 -2
  53. package/src/monorepo/__tests__/publish-workflow.test.ts +85 -13
  54. package/src/monorepo/ci-workflow.ts +17 -6
  55. package/src/monorepo/managed-files.test.ts +26 -0
  56. package/src/monorepo/managed-files.ts +36 -2
  57. package/src/monorepo/publish-workflow.ts +135 -21
  58. package/src/monorepo/secret-references.test.ts +1 -1
  59. package/src/release/__tests__/private-npm-status.test.ts +37 -0
  60. package/src/release/__tests__/private-npm-workflow.test.ts +20 -0
  61. package/src/release/index.ts +27 -2
  62. package/src/release/private-npm.ts +30 -5
  63. package/src/secrets/commands.ts +210 -0
  64. package/src/secrets/index.test.ts +92 -0
  65. package/src/secrets/index.ts +183 -0
  66. package/src/wrangler/deploy-stage.test.ts +190 -12
  67. package/src/wrangler/deploy-stage.ts +72 -45
  68. package/src/wrangler/deployed-version.test.ts +150 -0
  69. package/src/wrangler/deployed-version.ts +130 -0
  70. package/src/wrangler/live-version.ts +363 -0
@@ -0,0 +1,326 @@
1
+ import * as _validateReport_1 from "typia/lib/internal/_validateReport";
2
+ // What is live right now, as opposed to what we last uploaded.
3
+ //
4
+ // `wrangler versions deploy` returns when Cloudflare ACCEPTS the traffic shift, not when the edge
5
+ // serves it. A deploy that returns is therefore not yet a deploy that answers: the site that signs
6
+ // in to its stage's backend measured the previous backend version's response about 20 s after the
7
+ // backend's deploy step had already gone green. Everything here exists to turn "we asked" into
8
+ // "we observed".
9
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
10
+ import { dirname, join } from 'node:path';
11
+ import typia from 'typia';
12
+ const isUnknownRecord = (() => {
13
+ const _io0 = input => Object.keys(input).every(key => {
14
+ const value = input[key];
15
+ if (undefined === value)
16
+ return true;
17
+ return true;
18
+ });
19
+ return input => "object" === typeof input && null !== input && false === Array.isArray(input) && _io0(input);
20
+ })();
21
+ /**
22
+ * Every `{ id -> tag }` pair the payload carries, in document order.
23
+ *
24
+ * Wrangler nests versions differently per subcommand and per version of itself, spells the tag
25
+ * three ways (`annotations['workers/tag']`, a bare `tag`, `metadata.tag`) and the id two (`id`,
26
+ * `version_id`). One walk collects the relation once; both directions of the lookup are then a Map
27
+ * read rather than a second recursive scan that could disagree with the first.
28
+ */
29
+ export function collectVersionTags(value) {
30
+ const pairs = new Map();
31
+ collectInto(value, pairs);
32
+ return pairs;
33
+ }
34
+ function collectInto(value, pairs) {
35
+ if (Array.isArray(value)) {
36
+ for (const entry of value)
37
+ collectInto(entry, pairs);
38
+ return;
39
+ }
40
+ if (!isUnknownRecord(value))
41
+ return;
42
+ const annotations = isUnknownRecord(value.annotations) ? value.annotations : undefined;
43
+ const metadata = isUnknownRecord(value.metadata) ? value.metadata : undefined;
44
+ const annotationTag = annotations?.['workers/tag'];
45
+ const candidateTag = typeof annotationTag === 'string' ? annotationTag : typeof value.tag === 'string' ? value.tag : metadata?.tag;
46
+ const candidateId = typeof value.id === 'string' ? value.id : typeof value.version_id === 'string' ? value.version_id : undefined;
47
+ if (typeof candidateTag === 'string' && candidateId !== undefined && !pairs.has(candidateId)) {
48
+ pairs.set(candidateId, candidateTag);
49
+ }
50
+ for (const nested of Object.values(value))
51
+ collectInto(nested, pairs);
52
+ }
53
+ /** The version id whose tag is `tag`, or null. */
54
+ export function findVersionIdByTag(value, tag) {
55
+ for (const [id, candidate] of collectVersionTags(value)) {
56
+ if (candidate === tag)
57
+ return id;
58
+ }
59
+ return null;
60
+ }
61
+ /**
62
+ * The current deployment's traffic split, or null when the payload names no deployment at all.
63
+ *
64
+ * "Nothing is deployed" and "two versions share the traffic" are different facts with different
65
+ * remedies, and a caller that collapses them cannot tell an operator which one it hit — so the
66
+ * split is returned whole and both questions are answered from it.
67
+ */
68
+ export function currentDeploymentVersions(value) {
69
+ if (Array.isArray(value)) {
70
+ for (const entry of value) {
71
+ const found = currentDeploymentVersions(entry);
72
+ if (found)
73
+ return found;
74
+ }
75
+ return null;
76
+ }
77
+ if (!isUnknownRecord(value))
78
+ return null;
79
+ if (Array.isArray(value.versions)) {
80
+ return value.versions.map((version) => {
81
+ if (!isUnknownRecord(version))
82
+ return { versionId: null, percentage: 0 };
83
+ const id = version.version_id ?? version.id;
84
+ return { versionId: typeof id === 'string' ? id : null, percentage: Number(version.percentage) };
85
+ });
86
+ }
87
+ for (const nested of Object.values(value)) {
88
+ const found = currentDeploymentVersions(nested);
89
+ if (found)
90
+ return found;
91
+ }
92
+ return null;
93
+ }
94
+ /**
95
+ * The one version serving 100% of traffic, or null when there is none or the deployment is split.
96
+ *
97
+ * A split is not "close enough": with two versions live, "is our version live" has no single
98
+ * answer, and answering it optimistically is how a deploy reports success while part of the
99
+ * traffic still reaches the old code.
100
+ */
101
+ export function currentDeploymentVersionId(value) {
102
+ const shares = currentDeploymentVersions(value);
103
+ if (shares?.length !== 1)
104
+ return null;
105
+ const [only] = shares;
106
+ return only.percentage === 100 ? only.versionId : null;
107
+ }
108
+ /** The version serving all traffic for this worker, with the tag it was uploaded under. */
109
+ export async function readLiveVersion(probe) {
110
+ const deployments = await probe.deployments();
111
+ const shares = currentDeploymentVersions(deployments);
112
+ const versionId = currentDeploymentVersionId(deployments);
113
+ if (!versionId) {
114
+ return shares && shares.length > 0
115
+ ? {
116
+ ok: false,
117
+ error: {
118
+ kind: 'split-traffic',
119
+ message: 'no single worker version is serving 100% of traffic; refusing to name one as live',
120
+ },
121
+ }
122
+ : { ok: false, error: { kind: 'never-deployed', message: 'this worker has no active deployment' } };
123
+ }
124
+ const tag = collectVersionTags(await probe.versions()).get(versionId) ?? null;
125
+ return { ok: true, value: { versionId, tag } };
126
+ }
127
+ /**
128
+ * How long a deploy waits for the version it just activated to become the one being served.
129
+ *
130
+ * The bound is the point: an unbounded loop turns a stuck propagation into a hung CI job nobody
131
+ * reads, and a fixed sleep turns it into a green job that lied. Both are worse than a red job
132
+ * naming what it expected and what it saw.
133
+ */
134
+ export const LIVE_VERSION_WAIT_BUDGET_MS = 120_000;
135
+ /** Cloudflare's control plane converges in seconds; polling faster than this only burns API quota. */
136
+ export const LIVE_VERSION_POLL_INTERVAL_MS = 2_000;
137
+ /**
138
+ * Runs `attempt` until it succeeds or the budget expires, and reports the LAST thing it saw.
139
+ *
140
+ * Both waits below need the same deadline arithmetic and the same "what did you see instead"
141
+ * record; sharing it keeps the two from drifting into disagreeing about when a wait is over.
142
+ */
143
+ async function pollWithinBudget(attempt, options) {
144
+ const budgetMs = options.budgetMs ?? LIVE_VERSION_WAIT_BUDGET_MS;
145
+ const intervalMs = options.intervalMs ?? LIVE_VERSION_POLL_INTERVAL_MS;
146
+ const now = options.now ?? Date.now;
147
+ const sleep = options.sleep ?? sleepFor;
148
+ const deadline = now() + budgetMs;
149
+ for (;;) {
150
+ const result = await attempt();
151
+ if (result.done)
152
+ return { ok: true, value: result.value };
153
+ if (now() >= deadline)
154
+ return { ok: false, error: { observed: result.observed, waitedMs: budgetMs } };
155
+ await sleep(intervalMs);
156
+ }
157
+ }
158
+ function sleepFor(ms) {
159
+ const { promise, resolve } = Promise.withResolvers();
160
+ setTimeout(resolve, ms);
161
+ return promise;
162
+ }
163
+ /**
164
+ * Polls until the worker's live version carries `expectedTag`, or the budget runs out.
165
+ *
166
+ * The tag is the match, not the version id: the tag IS the desired-state identity (`nx-<task
167
+ * hash>`), it is what both `wrangler deploy --tag` and `wrangler versions deploy --version-tag`
168
+ * were told to make live, and it is the one name the caller knows before the upload has produced
169
+ * a version id.
170
+ *
171
+ * Returns the failure rather than throwing it: a deploy that cannot be observed is an operational
172
+ * outcome the caller has to report, not a broken invariant.
173
+ */
174
+ export async function awaitLiveVersion(probe, expectedTag, options = {}) {
175
+ const polled = await pollWithinBudget(async () => {
176
+ const live = await readLiveVersion(probe);
177
+ if (!live.ok)
178
+ return { done: false, observed: live.error.message };
179
+ const { tag, versionId } = live.value;
180
+ if (tag === expectedTag)
181
+ return { done: true, value: live.value };
182
+ return { done: false, observed: tag ? `${tag} (${versionId})` : `an untagged version (${versionId})` };
183
+ }, options);
184
+ if (polled.ok)
185
+ return polled;
186
+ return {
187
+ ok: false,
188
+ error: {
189
+ kind: 'not-observed',
190
+ expected: expectedTag,
191
+ observed: polled.error.observed,
192
+ message: `deployed version ${expectedTag} was not serving traffic after ` +
193
+ `${Math.round(polled.error.waitedMs / 1000)}s; live is ${polled.error.observed}`,
194
+ },
195
+ };
196
+ }
197
+ /**
198
+ * Polls a worker-served endpoint until it answers with `expectedTag`.
199
+ *
200
+ * The control plane agreeing is necessary and not sufficient — that gap is exactly what this
201
+ * change's predecessor measured — so a project that can prove the edge serves the new code says so
202
+ * with an endpoint echoing its own version tag (Workers read it from the version-metadata
203
+ * binding). The contract is deliberately one shape: the trimmed response body IS the tag.
204
+ */
205
+ export async function awaitVersionEndpoint(url, expectedTag, options = {}) {
206
+ const request = options.fetch ?? fetch;
207
+ const polled = await pollWithinBudget(async () => {
208
+ const observed = await probeVersionEndpoint(request, url);
209
+ return observed === expectedTag ? { done: true, value: undefined } : { done: false, observed };
210
+ }, options);
211
+ if (polled.ok)
212
+ return polled;
213
+ return {
214
+ ok: false,
215
+ error: {
216
+ kind: 'not-observed',
217
+ expected: expectedTag,
218
+ observed: polled.error.observed,
219
+ message: `${url} did not report version ${expectedTag} after ` +
220
+ `${Math.round(polled.error.waitedMs / 1000)}s; it reported ${polled.error.observed}`,
221
+ },
222
+ };
223
+ }
224
+ async function probeVersionEndpoint(request, url) {
225
+ try {
226
+ const response = await request(url, { headers: { accept: 'text/plain' } });
227
+ const body = (await response.text()).trim();
228
+ return response.ok ? body : `HTTP ${response.status} ${body}`.trim();
229
+ }
230
+ catch (error) {
231
+ // A worker mid-rollout refuses connections; that is a poll result, not a reason to abandon the
232
+ // wait. It only becomes the failure when it is still the answer at the deadline.
233
+ return error instanceof Error ? error.message : String(error);
234
+ }
235
+ }
236
+ /**
237
+ * How long `smoo wrangler deployed-version` trusts its own last answer.
238
+ *
239
+ * This TTL is a convenience for operators and repeated local queries, NOT a correctness mechanism:
240
+ * nothing that decides whether to deploy may read it. The deploy's own liveness check always calls
241
+ * Cloudflare, because a cached "live already equals the desired tag" is precisely the belief a
242
+ * rollback falsifies, and acting on it would skip the deploy that repairs the rollback.
243
+ */
244
+ export const LIVE_VERSION_CACHE_TTL_MS = 45_000;
245
+ const parseCachedLiveVersion = (() => {
246
+ const _io0 = input => (null === input.versionTag || "string" === typeof input.versionTag) && "string" === typeof input.versionId && "number" === typeof input.fetchedAt;
247
+ const _vo0 = (input, _path, _exceptionable = true) => [null === input.versionTag || "string" === typeof input.versionTag || _report(_exceptionable, {
248
+ path: _path + ".versionTag",
249
+ expected: "(null | string)",
250
+ value: input.versionTag
251
+ }), "string" === typeof input.versionId || _report(_exceptionable, {
252
+ path: _path + ".versionId",
253
+ expected: "string",
254
+ value: input.versionId
255
+ }), "number" === typeof input.fetchedAt || _report(_exceptionable, {
256
+ path: _path + ".fetchedAt",
257
+ expected: "number",
258
+ value: input.fetchedAt
259
+ })].every(flag => flag);
260
+ const __is = input => "object" === typeof input && null !== input && _io0(input);
261
+ let errors;
262
+ let _report;
263
+ const __validate = input => {
264
+ if (false === __is(input)) {
265
+ errors = [];
266
+ _report = _validateReport_1._validateReport(errors);
267
+ ((input, _path, _exceptionable = true) => ("object" === typeof input && null !== input || _report(true, {
268
+ path: _path + "",
269
+ expected: "CachedLiveVersion",
270
+ value: input
271
+ })) && _vo0(input, _path + "", true) || _report(true, {
272
+ path: _path + "",
273
+ expected: "CachedLiveVersion",
274
+ value: input
275
+ }))(input, "$input", true);
276
+ const success = 0 === errors.length;
277
+ return success ? {
278
+ success,
279
+ data: input
280
+ } : {
281
+ success,
282
+ errors,
283
+ data: input
284
+ };
285
+ }
286
+ return {
287
+ success: true,
288
+ data: input
289
+ };
290
+ };
291
+ return input => __validate(JSON.parse(input));
292
+ })();
293
+ /**
294
+ * One file per key, so two deploys running in parallel cannot clobber each other's answer; a
295
+ * shared map would need a lock to say the same thing.
296
+ */
297
+ export function liveVersionCachePath(cacheDirectory, key) {
298
+ const name = [key.accountId, key.workerName, key.stage].map(encodeURIComponent).join('_');
299
+ return join(cacheDirectory, 'smoo-live-version', `${name}.json`);
300
+ }
301
+ export async function readCachedLiveVersion(cacheDirectory, key, ttlMs, now = Date.now) {
302
+ let text;
303
+ try {
304
+ text = await readFile(liveVersionCachePath(cacheDirectory, key), 'utf8');
305
+ }
306
+ catch {
307
+ return null;
308
+ }
309
+ const parsed = parseCachedLiveVersion(text);
310
+ if (!parsed.success)
311
+ return null;
312
+ return now() - parsed.data.fetchedAt < ttlMs ? parsed.data : null;
313
+ }
314
+ export async function writeCachedLiveVersion(cacheDirectory, key, entry) {
315
+ const path = liveVersionCachePath(cacheDirectory, key);
316
+ await mkdir(dirname(path), { recursive: true });
317
+ await writeFile(path, `${JSON.stringify(entry)}\n`);
318
+ }
319
+ /**
320
+ * Where the cache lives. Nx's workspace data directory already exists, is already gitignored, and
321
+ * is already wiped when the workspace is reset — three properties a hand-rolled directory would
322
+ * each have to re-earn, and one ($HOME) that would leak one checkout's answers into another's.
323
+ */
324
+ export function liveVersionCacheDirectory(workspaceRoot, environment) {
325
+ return environment.NX_WORKSPACE_DATA_DIRECTORY ?? join(workspaceRoot, '.nx', 'workspace-data');
326
+ }
@@ -372,9 +372,11 @@
372
372
  esac
373
373
  ''}
374
374
  '')
375
- # Epilogue: the wrapper runs devenv from tooling/direnv, so return the shell
376
- # to wherever the caller invoked it. Last, after every project step.
375
+ # Epilogue, after every project step (a project's own enterShell may resolve
376
+ # SDKROOT/compilers; the toolchain identity must see the final values). The
377
+ # stamp logic lives in toolchain-stamp.ts; see its header for why.
377
378
  (lib.mkAfter ''
379
+ bun "$DEVENV_ROOT/toolchain-stamp.ts" || exit $?
378
380
  if [ -n "$DEVENV_SHELL_PWD" ]; then
379
381
  cd "$DEVENV_SHELL_PWD"
380
382
  fi
@@ -145,7 +145,10 @@ build_devenv_shell() {
145
145
  # One evaluation: the shell that captures the environment is the shell
146
146
  # build. A separate `devenv shell -- date` evaluated everything twice.
147
147
  persist_devenv_environment
148
- if [ "${SMOO_HOST_RUNNER:-false}" != true ]; then
148
+ # Only the ephemeral-runner path of setup-devenv opts in: the scan walks the
149
+ # whole store, which on a developer host or a host runner is large and not
150
+ # ours to root.
151
+ if [ "${SMOO_ROOT_BUILD_INPUTS:-false}" = true ]; then
149
152
  root_build_inputs "$since"
150
153
  fi
151
154
  # Add repo-local tools only after the shell exists; cleanup steps use an
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Invalidate cmake build-script caches when the toolchain changes.
4
+ *
5
+ * cmake-rs keeps CMAKE_OSX_SYSROOT and the compilers in CMakeCache.txt from
6
+ * the FIRST configure and only clears a build directory when the source path
7
+ * moves. Every C/C++ build script's cache under target/ therefore encodes
8
+ * whichever toolchain the shell had when it was first configured, and it
9
+ * outlives every later fix to that shell: a nix-store sysroot cached on one
10
+ * day kept failing `ld: library 'c++' not found` after the shell stopped
11
+ * exporting it. Nx does not see this state either — it hashes inputs, not
12
+ * what a cache under target/ remembers.
13
+ *
14
+ * Runs at the end of shell entry, after the project's own enterShell resolved
15
+ * SDKROOT and the compilers. It records the toolchain identity the shell
16
+ * settled on and, when that identity differs from the recorded one, removes
17
+ * exactly the cargo build-script directories that hold a CMakeCache.txt, so
18
+ * cargo re-runs those scripts against the current toolchain. Nothing else
19
+ * under target/ is touched; a checkout without target/ has nothing to do.
20
+ */
21
+ import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
22
+ import { join, resolve } from 'node:path';
23
+ import { $ } from 'bun';
24
+
25
+ const root = resolve(`${process.env.DEVENV_ROOT ?? process.cwd()}/../..`);
26
+ const target = join(root, 'target');
27
+ if (existsSync(target)) {
28
+ const stamp = join(target, '.toolchain-stamp');
29
+ const identity = await toolchainIdentity();
30
+ const recorded = existsSync(stamp) ? readFileSync(stamp, 'utf8') : null;
31
+ if (recorded !== identity) {
32
+ const removed = removeCmakeBuildScriptDirs(target);
33
+ if (removed > 0) {
34
+ console.error(`devenv: toolchain identity changed; removed ${removed} cmake build-script cache(s) under target/`);
35
+ }
36
+ writeFileSync(stamp, identity);
37
+ }
38
+ }
39
+
40
+ async function toolchainIdentity(): Promise<string> {
41
+ const rustc = await $`rustc -Vv`.quiet().nothrow().text();
42
+ const cc = (await $`cc --version`.quiet().nothrow().text()).split('\n')[0] ?? '';
43
+ return [
44
+ rustc.trim(),
45
+ `SDKROOT=${process.env.SDKROOT ?? ''}`,
46
+ `DEVELOPER_DIR=${process.env.DEVELOPER_DIR ?? ''}`,
47
+ cc,
48
+ ].join('\n');
49
+ }
50
+
51
+ /**
52
+ * Removes every cargo build-script directory (`…/build/<crate>-<hash>`) whose
53
+ * `out/build/CMakeCache.txt` exists; returns the count. Only the `build/`
54
+ * directories cargo creates under a profile are scanned - never the compiled
55
+ * artifacts beside them - and the scan completes before anything is removed.
56
+ */
57
+ function removeCmakeBuildScriptDirs(target: string): number {
58
+ const stale: string[] = [];
59
+ for (const buildDir of cargoBuildDirs(target, 0)) {
60
+ for (const entry of readdirSync(buildDir, { withFileTypes: true })) {
61
+ if (entry.isDirectory() && existsSync(join(buildDir, entry.name, 'out', 'build', 'CMakeCache.txt'))) {
62
+ stale.push(join(buildDir, entry.name));
63
+ }
64
+ }
65
+ }
66
+ for (const dir of stale) {
67
+ rmSync(dir, { recursive: true, force: true });
68
+ }
69
+ return stale.length;
70
+ }
71
+
72
+ /** `target/[<lane>/][<triple>/]<profile>/build` - at most four levels below target/. */
73
+ function* cargoBuildDirs(dir: string, depth: number): Generator<string> {
74
+ if (depth > 4) {
75
+ return;
76
+ }
77
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
78
+ if (!entry.isDirectory()) {
79
+ continue;
80
+ }
81
+ const path = join(dir, entry.name);
82
+ if (entry.name === 'build' && depth > 0) {
83
+ yield path;
84
+ } else if (entry.name !== 'deps' && entry.name !== 'incremental' && entry.name !== '.fingerprint') {
85
+ yield* cargoBuildDirs(path, depth + 1);
86
+ }
87
+ }
88
+ }
@@ -237,5 +237,5 @@ runs:
237
237
  shell: bash
238
238
  working-directory: ${{ env.DEVENV_WORKDIR }}/tooling/direnv
239
239
  env:
240
- SMOO_HOST_RUNNER: ${{ steps.runner-kind.outputs.host }}
240
+ SMOO_ROOT_BUILD_INPUTS: ${{ steps.runner-kind.outputs.host != 'true' }}
241
241
  run: ./github-actions-bootstrap.sh build-shell
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smoothbricks/cli",
3
- "version": "0.11.15",
3
+ "version": "0.11.17",
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.9",
67
+ "@smoothbricks/nx-plugin": "0.4.11",
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
@@ -6,7 +6,9 @@ import { cliPackageVersion } from './lib/cli-package.js';
6
6
  import { decode, findRepoRoot, printCommandOutput } from './lib/run.js';
7
7
  import { ensureChromium } from './playwright/index.js';
8
8
  import { resolvePrConflicts } from './pr/index.js';
9
+ import { secretsSet, secretsStatus, secretsSync } from './secrets/commands.js';
9
10
  import { cleanupPullRequest, deployStage } from './wrangler/deploy-stage.js';
11
+ import { deployedVersion } from './wrangler/deployed-version.js';
10
12
  import { scaffold } from './wrangler/scaffold.js';
11
13
 
12
14
  export async function runCli(argv = process.argv.slice(2)): Promise<void> {
@@ -525,6 +527,31 @@ function buildProgram(): Command {
525
527
  await ensureChromium();
526
528
  });
527
529
 
530
+ const secrets = program
531
+ .command('secrets')
532
+ .description('Reconcile declared secrets: what Workers need, what workflows pass, what the repository holds');
533
+ secrets
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 }) => {
538
+ process.exitCode = secretsStatus(await findRepoRoot(), options);
539
+ });
540
+ 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);
546
+ });
547
+ secrets
548
+ .command('sync')
549
+ .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 }) => {
552
+ process.exitCode = await secretsSync(await findRepoRoot(), options);
553
+ });
554
+
528
555
  const wrangler = program.command('wrangler').description('Cloudflare wrangler project helpers');
529
556
  wrangler
530
557
  .command('scaffold <project>')
@@ -537,11 +564,29 @@ function buildProgram(): Command {
537
564
  .command('deploy-stage')
538
565
  .requiredOption('--stage <stage>', 'staging, production, or prN')
539
566
  .option('--config <path>', 'deploy a build-generated flat wrangler.json instead of ./wrangler.toml')
540
- .action(async (options: { stage: string; config?: string }) => {
567
+ .option('--version-endpoint <url>', 'URL served by this worker whose trimmed body is the running version tag')
568
+ .action(async (options: { stage: string; config?: string; versionEndpoint?: string }) => {
541
569
  await deployStage(process.cwd(), {
542
570
  stage: options.stage,
543
571
  ...(options.config ? { config: resolve(options.config) } : {}),
572
+ ...(options.versionEndpoint ? { versionEndpoint: options.versionEndpoint } : {}),
573
+ });
574
+ });
575
+ wrangler
576
+ .command('deployed-version')
577
+ .description('Print the version tag serving all traffic for this project\u2019s worker on a stage')
578
+ .requiredOption('--stage <stage>', 'staging, production, or prN')
579
+ .option('--config <path>', 'resolve the worker name from a build-generated flat wrangler.json')
580
+ .option('--refresh', 'ask Cloudflare even when a fresh cached answer exists')
581
+ .action(async (options: { stage: string; config?: string; refresh?: boolean }) => {
582
+ const report = await deployedVersion(process.cwd(), {
583
+ stage: options.stage,
584
+ ...(options.config ? { config: resolve(options.config) } : {}),
585
+ ...(options.refresh ? { refresh: true } : {}),
544
586
  });
587
+ // The tag alone on stdout: this is read by humans and by `$(...)`, and a version deployed
588
+ // outside Nx genuinely has no tag, which `untagged` says without pretending to be one.
589
+ console.log(report.versionTag ?? 'untagged');
545
590
  });
546
591
  wrangler
547
592
  .command('cleanup-pr')