@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.
- package/README.md +43 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +44 -0
- package/dist/github-ci/index.d.ts +1 -1
- package/dist/github-ci/index.d.ts.map +1 -1
- package/dist/github-ci/index.js +7 -10
- package/dist/lib/json.d.ts +9 -0
- package/dist/lib/json.d.ts.map +1 -1
- package/dist/lib/json.js +65 -53
- package/dist/lib/secret-names.d.ts +35 -0
- package/dist/lib/secret-names.d.ts.map +1 -0
- package/dist/lib/secret-names.js +61 -0
- package/dist/monorepo/ci-workflow.d.ts.map +1 -1
- package/dist/monorepo/ci-workflow.js +15 -6
- package/dist/monorepo/managed-files.d.ts +9 -0
- package/dist/monorepo/managed-files.d.ts.map +1 -1
- package/dist/monorepo/managed-files.js +28 -2
- package/dist/monorepo/publish-workflow.d.ts +10 -1
- package/dist/monorepo/publish-workflow.d.ts.map +1 -1
- package/dist/monorepo/publish-workflow.js +110 -22
- package/dist/release/index.d.ts +1 -1
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +26 -4
- package/dist/release/private-npm.d.ts +5 -0
- package/dist/release/private-npm.d.ts.map +1 -1
- package/dist/release/private-npm.js +26 -5
- package/dist/secrets/commands.d.ts +45 -0
- package/dist/secrets/commands.d.ts.map +1 -0
- package/dist/secrets/commands.js +189 -0
- package/dist/secrets/index.d.ts +83 -0
- package/dist/secrets/index.d.ts.map +1 -0
- package/dist/secrets/index.js +149 -0
- package/dist/wrangler/deploy-stage.d.ts +13 -2
- package/dist/wrangler/deploy-stage.d.ts.map +1 -1
- package/dist/wrangler/deploy-stage.js +39 -54
- package/dist/wrangler/deployed-version.d.ts +44 -0
- package/dist/wrangler/deployed-version.d.ts.map +1 -0
- package/dist/wrangler/deployed-version.js +87 -0
- package/dist/wrangler/live-version.d.ts +146 -0
- package/dist/wrangler/live-version.d.ts.map +1 -0
- package/dist/wrangler/live-version.js +326 -0
- package/managed/raw/tooling/direnv/devenv.smoo.nix +4 -2
- package/managed/raw/tooling/direnv/github-actions-bootstrap.sh +4 -1
- package/managed/raw/tooling/direnv/toolchain-stamp.ts +88 -0
- package/managed/templates/github/actions/setup-devenv/action.yml +1 -1
- package/package.json +2 -2
- package/src/cli.ts +46 -1
- package/src/github-ci/index.test.ts +92 -8
- package/src/github-ci/index.ts +7 -10
- package/src/lib/json.ts +9 -0
- package/src/lib/secret-names.ts +70 -0
- package/src/monorepo/__tests__/ci-workflow.test.ts +27 -2
- package/src/monorepo/__tests__/publish-workflow.test.ts +85 -13
- package/src/monorepo/ci-workflow.ts +17 -6
- package/src/monorepo/managed-files.test.ts +26 -0
- package/src/monorepo/managed-files.ts +36 -2
- package/src/monorepo/publish-workflow.ts +135 -21
- package/src/monorepo/secret-references.test.ts +1 -1
- package/src/release/__tests__/private-npm-status.test.ts +37 -0
- package/src/release/__tests__/private-npm-workflow.test.ts +20 -0
- package/src/release/index.ts +27 -2
- package/src/release/private-npm.ts +30 -5
- package/src/secrets/commands.ts +210 -0
- package/src/secrets/index.test.ts +92 -0
- package/src/secrets/index.ts +183 -0
- package/src/wrangler/deploy-stage.test.ts +190 -12
- package/src/wrangler/deploy-stage.ts +72 -45
- package/src/wrangler/deployed-version.test.ts +150 -0
- package/src/wrangler/deployed-version.ts +130 -0
- 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
|
|
376
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
.
|
|
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')
|