@spunto/build 0.5.0 → 0.6.0
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/package.json +2 -2
- package/src/script/setup-script.ts +169 -23
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spunto/build",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Spunto's shared Build engine
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Spunto's shared Build engine — the devcontainer image protocol and VS Code extension registry clients, with no database, no HTTP framework and no UI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"homepage": "https://spunto.net",
|
|
@@ -35,6 +35,37 @@ function shQuote(v: string): string {
|
|
|
35
35
|
return `'${v.replace(/'/g, `'\\''`)}'`
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Runs a command as the workspace user instead of root.
|
|
40
|
+
*
|
|
41
|
+
* The setup script itself is the container's CMD, so it runs as root — it has to, to chown
|
|
42
|
+
* /workspace and write under /etc. Everything it *clones*, though, belongs to the user, and
|
|
43
|
+
* cloning as root has cost us twice: once as `npm install` failing with EACCES on a root-owned
|
|
44
|
+
* node_modules (hence the re-chown after the repo clones), and once as a private dotfiles repo
|
|
45
|
+
* that authenticated with no key at all, because root has no `~/.ssh` and the identity was only
|
|
46
|
+
* named on the repo clones.
|
|
47
|
+
*
|
|
48
|
+
* `su` gives the command `HOME=/home/vscode`, which is what makes ssh pick up the `~/.ssh/config`
|
|
49
|
+
* written in the credentials step — so a clone over SSH needs nothing else to find the user's key.
|
|
50
|
+
*/
|
|
51
|
+
function asUser(username: string, cmd: string): string {
|
|
52
|
+
return `su ${username} -c ${shQuote(cmd)}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A dotfiles setting, as a clonable URL.
|
|
57
|
+
*
|
|
58
|
+
* Anything already naming a transport is left alone; only a bare `owner/repo` shorthand is
|
|
59
|
+
* expanded against GitHub. The previous test — `startsWith("http") || startsWith("git@")` — turned
|
|
60
|
+
* `ssh://git@host/me/dotfiles.git` into `https://github.com/ssh://git@host/me/dotfiles.git`.
|
|
61
|
+
*/
|
|
62
|
+
function normalizeDotfilesUrl(raw: string): string {
|
|
63
|
+
const v = raw.trim()
|
|
64
|
+
return /^(https?:\/\/|ssh:\/\/|git:\/\/|file:\/\/|[^/\s]+@[^/\s]+:)/.test(v)
|
|
65
|
+
? v
|
|
66
|
+
: `https://github.com/${v.replace(/^\/+|\/+$/g, "")}`
|
|
67
|
+
}
|
|
68
|
+
|
|
38
69
|
/**
|
|
39
70
|
* `export EXTENSIONS_GALLERY=…`, or nothing when the org is on the default (Open VSX) registry.
|
|
40
71
|
*
|
|
@@ -105,11 +136,15 @@ function cloneRepoBlock(params: {
|
|
|
105
136
|
index: number
|
|
106
137
|
total: number
|
|
107
138
|
homeDir: string
|
|
139
|
+
/** Who the checkout belongs to — the clone runs as them. */
|
|
140
|
+
username: string
|
|
108
141
|
branch?: string
|
|
109
142
|
githubInstallationTokens?: Record<string, string>
|
|
110
143
|
userSshPrivateKey?: string
|
|
144
|
+
/** Whether that key is registered on the member's provider account — see `originSetUrl`. */
|
|
145
|
+
userSshKeyRegistered?: boolean
|
|
111
146
|
}): { header: string; lines: string[] } {
|
|
112
|
-
const { repo: r, index, total, homeDir, branch, githubInstallationTokens, userSshPrivateKey } = params
|
|
147
|
+
const { repo: r, index, total, homeDir, username, branch, githubInstallationTokens, userSshPrivateKey, userSshKeyRegistered } = params
|
|
113
148
|
// Pick the installation token for this repo's owner. A GitHub App installation only grants
|
|
114
149
|
// access to repos owned by its account, so the owner login (== installation accountLogin)
|
|
115
150
|
// uniquely selects the right token among the org's connected installations. Covers legacy
|
|
@@ -117,13 +152,22 @@ function cloneRepoBlock(params: {
|
|
|
117
152
|
const owner = (r.project.split("/")[0] ?? "").toLowerCase()
|
|
118
153
|
const repoToken = githubInstallationTokens?.[owner]
|
|
119
154
|
const b = branch ? ` --branch ${shQuote(branch)}` : ""
|
|
120
|
-
const
|
|
155
|
+
const rawCloneCmd = r.provider === "git" && r.cloneUrl
|
|
121
156
|
? `GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ${homeDir}/.ssh/mp_deploy_key" git clone${b} ${shQuote(r.cloneUrl)} /workspace/${r.workspacePath}`
|
|
122
157
|
: r.provider === "github" && repoToken
|
|
123
|
-
? `git clone${b} https://x-access-token:${repoToken}@github.com/${r.project}.git /workspace/${r.workspacePath} &&
|
|
158
|
+
? `git clone${b} https://x-access-token:${repoToken}@github.com/${r.project}.git /workspace/${r.workspacePath} && ${originSetUrl(r, `/workspace/${r.workspacePath}`, userSshKeyRegistered)}`
|
|
124
159
|
: r.provider === "github" && userSshPrivateKey
|
|
125
160
|
? `GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ${homeDir}/.ssh/mp_user_key" git clone${b} git@github.com:${r.project}.git /workspace/${r.workspacePath}`
|
|
126
|
-
:
|
|
161
|
+
// Last resort: a public clone over https. Only valid for GitHub — for any other hosting
|
|
162
|
+
// provider this would quietly clone the wrong host, so say what happened instead. A provider
|
|
163
|
+
// reaches this line only when its credential could not be minted, which is worth reading in
|
|
164
|
+
// the setup log rather than debugging from a confusing 404.
|
|
165
|
+
: r.provider === "github"
|
|
166
|
+
? `git clone${b} https://github.com/${r.project} /workspace/${r.workspacePath}`
|
|
167
|
+
: `echo "No clone credential for ${r.provider} repository ${r.project} — check the organization's integrations." >&2 && false`
|
|
168
|
+
// Cloned as the user, not as root: /workspace is already theirs (the ownership step runs
|
|
169
|
+
// first), so the checkout lands with the right owner and needs no chown afterwards.
|
|
170
|
+
const cloneCmd = asUser(username, rawCloneCmd)
|
|
127
171
|
|
|
128
172
|
// Single-quoted only when a (user-provided) branch is interpolated; the plain form is kept
|
|
129
173
|
// verbatim for the no-branch case so the generated script is unchanged there.
|
|
@@ -173,6 +217,74 @@ function cloneRepoBlock(params: {
|
|
|
173
217
|
* both, and also makes the credentials usable by everything that runs as the user *during* setup
|
|
174
218
|
* (postCreateCommand, dotfiles install script).
|
|
175
219
|
*/
|
|
220
|
+
/**
|
|
221
|
+
* The credential helper's own source, kept whole rather than assembled line by line: it is a
|
|
222
|
+
* shell script inside a TypeScript string inside a heredoc, and every layer of escaping added
|
|
223
|
+
* to that is a layer someone has to debug later.
|
|
224
|
+
*/
|
|
225
|
+
/**
|
|
226
|
+
* Where `origin` points after an installation-token clone.
|
|
227
|
+
*
|
|
228
|
+
* The clone itself is the organization's; `origin` decides who everything *after* it acts as.
|
|
229
|
+
* Three cases, in this order:
|
|
230
|
+
*
|
|
231
|
+
* 1. **The worker has an identity** (`SPUNTO_WORKER_TOKEN`) → HTTPS, and the credential helper
|
|
232
|
+
* answers as the member who owns it (RFC 0024). This is the normal case now: every spawn mints
|
|
233
|
+
* one. Decided in shell rather than here so it stays locked to the same condition the helper's
|
|
234
|
+
* own install is guarded on — two places, one signal.
|
|
235
|
+
* 2. **No identity, but the member's key is registered on their account** → SSH, the old path.
|
|
236
|
+
* 3. **Neither** → HTTPS with nothing behind it. Public repos work; a private one fails *at the
|
|
237
|
+
* push*, which is the honest outcome, rather than an SSH refusal that says nothing.
|
|
238
|
+
*
|
|
239
|
+
* Note what case 2 keys on: the key being **registered**, not merely existing. A key is generated
|
|
240
|
+
* for every account at signup (`auth.router.ts`) and only ever registered on the provider when
|
|
241
|
+
* someone connects it — so "has a private key" is true for everyone and means nothing here.
|
|
242
|
+
*/
|
|
243
|
+
function originSetUrl(
|
|
244
|
+
r: { project: string },
|
|
245
|
+
path: string,
|
|
246
|
+
userSshKeyRegistered?: boolean,
|
|
247
|
+
): string {
|
|
248
|
+
const https = `git -C ${path} remote set-url origin https://github.com/${r.project}.git`
|
|
249
|
+
const ssh = `git -C ${path} remote set-url origin git@github.com:${r.project}.git`
|
|
250
|
+
|
|
251
|
+
// **Undefined is a caller that predates this parameter**, and it gets exactly what it always
|
|
252
|
+
// got: the SSH rewrite. This package is consumed by published version, so a host can adopt a new
|
|
253
|
+
// release before it knows to pass the new field — and for that host there is no worker identity
|
|
254
|
+
// either, so answering HTTPS would leave it with an origin nothing can authenticate. Silence on
|
|
255
|
+
// a push, for every member with a registered key.
|
|
256
|
+
if (userSshKeyRegistered === undefined) return ssh
|
|
257
|
+
|
|
258
|
+
// Told, and the key is not registered: SSH would refuse, so stay on HTTPS and let the credential
|
|
259
|
+
// helper answer as the member.
|
|
260
|
+
if (!userSshKeyRegistered) return https
|
|
261
|
+
|
|
262
|
+
// Told, and registered: the helper still comes first where the worker has an identity.
|
|
263
|
+
return `if [ -n "\${SPUNTO_WORKER_TOKEN:-}" ]; then ${https}; else ${ssh}; fi`
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const GIT_CREDENTIAL_HELPER = `#!/bin/sh
|
|
267
|
+
# Asks Spunto for a credential belonging to this worker's owner, at the moment git needs it.
|
|
268
|
+
# Only \`get\` is answered: there is nothing to store (we hold no cache) and nothing to erase.
|
|
269
|
+
[ "$1" = "get" ] || exit 0
|
|
270
|
+
while IFS= read -r line; do
|
|
271
|
+
case "$line" in
|
|
272
|
+
path=*) REPO="\${line#path=}" ;;
|
|
273
|
+
"") break ;;
|
|
274
|
+
esac
|
|
275
|
+
done
|
|
276
|
+
REPO="\${REPO%.git}"
|
|
277
|
+
RESPONSE=$(curl -fsS -m 20 -X POST "$SPUNTO_API_URL/api/workers/git-credential" -H "Authorization: Bearer $SPUNTO_WORKER_TOKEN" -H "Content-Type: application/json" -d "{\\"repo\\":\\"$REPO\\"}" 2>/dev/null)
|
|
278
|
+
PASSWORD=$(printf "%s" "$RESPONSE" | sed -n 's/.*"password":"\\([^"]*\\)".*/\\1/p')
|
|
279
|
+
if [ -z "$PASSWORD" ]; then
|
|
280
|
+
echo "spunto: no git credential for $REPO - authorize the provider in Integrations." >&2
|
|
281
|
+
exit 0
|
|
282
|
+
fi
|
|
283
|
+
USERNAME=$(printf "%s" "$RESPONSE" | sed -n 's/.*"username":"\\([^"]*\\)".*/\\1/p')
|
|
284
|
+
echo "username=$USERNAME"
|
|
285
|
+
echo "password=$PASSWORD"
|
|
286
|
+
`
|
|
287
|
+
|
|
176
288
|
function credentialsBlock(params: {
|
|
177
289
|
homeDir: string
|
|
178
290
|
username: string
|
|
@@ -200,6 +312,30 @@ function credentialsBlock(params: {
|
|
|
200
312
|
)
|
|
201
313
|
}
|
|
202
314
|
|
|
315
|
+
// Git credential helper (RFC 0024). Git runs this at the moment of a `fetch`/`push` and reads
|
|
316
|
+
// `username=` / `password=` from its stdout; anything else it prints is ignored, so a failure
|
|
317
|
+
// message on stderr reaches the user's terminal while git falls through to asking as before.
|
|
318
|
+
//
|
|
319
|
+
// Nothing durable is stored: the credential is fetched per operation and lives only in the
|
|
320
|
+
// process that asked for it. `credential.useHttpPath` is on because the platform resolves *which*
|
|
321
|
+
// provider hosts a repository from its `owner/name`, which git only sends when asked to.
|
|
322
|
+
//
|
|
323
|
+
// Guarded on the worker token being present: a worker spawned before RFC 0024, or one whose
|
|
324
|
+
// token could not be minted, simply keeps the previous behaviour instead of failing to start.
|
|
325
|
+
lines.push(
|
|
326
|
+
'if [ -n "${SPUNTO_WORKER_TOKEN:-}" ] && [ -n "${SPUNTO_API_URL:-}" ]; then',
|
|
327
|
+
' echo "Configuring git credential helper..."',
|
|
328
|
+
` mkdir -p ${homeDir}/.spunto`,
|
|
329
|
+
` cat > ${homeDir}/.spunto/git-credential-spunto <<'SPUNTO_HELPER'`,
|
|
330
|
+
GIT_CREDENTIAL_HELPER,
|
|
331
|
+
"SPUNTO_HELPER",
|
|
332
|
+
` chmod +x ${homeDir}/.spunto/git-credential-spunto`,
|
|
333
|
+
` git config --global credential.helper ${homeDir}/.spunto/git-credential-spunto`,
|
|
334
|
+
" git config --global credential.useHttpPath true",
|
|
335
|
+
' echo "Git credential helper configured"',
|
|
336
|
+
"fi",
|
|
337
|
+
)
|
|
338
|
+
|
|
203
339
|
// Per-project deploy key (RFC 0013) — used only for generic "git" repos, referenced per-clone
|
|
204
340
|
// via GIT_SSH_COMMAND (not added to ~/.ssh/config, so it never shadows the user's own key).
|
|
205
341
|
if (projectDeployKey) {
|
|
@@ -714,6 +850,13 @@ export type SetupScriptParams = {
|
|
|
714
850
|
workerId: string
|
|
715
851
|
userInfo?: { name: string; email?: string | null }
|
|
716
852
|
userSshPrivateKey?: string
|
|
853
|
+
/**
|
|
854
|
+
* Whether that key is **registered on the member's provider account**. Distinct from having one:
|
|
855
|
+
* every account is given a keypair at signup, and it is only ever registered when someone
|
|
856
|
+
* connects their provider — so `userSshPrivateKey` is set for everyone and says nothing about
|
|
857
|
+
* whether SSH would authenticate. See `originSetUrl`.
|
|
858
|
+
*/
|
|
859
|
+
userSshKeyRegistered?: boolean
|
|
717
860
|
sshGatewayPublicKey?: string
|
|
718
861
|
skipFeatures?: boolean
|
|
719
862
|
dotfilesRepo?: string | null
|
|
@@ -732,7 +875,7 @@ export type SetupScriptParams = {
|
|
|
732
875
|
}
|
|
733
876
|
|
|
734
877
|
export function buildSetupScript(params: SetupScriptParams): { script: string } {
|
|
735
|
-
const { project, workerId, userInfo, userSshPrivateKey, dotfilesRepo, userEnvSecrets, githubInstallationTokens, projectDeployKey, branch } = params
|
|
878
|
+
const { project, workerId, userInfo, userSshPrivateKey, userSshKeyRegistered, dotfilesRepo, userEnvSecrets, githubInstallationTokens, projectDeployKey, branch } = params
|
|
736
879
|
const homeDir = "/home/vscode"
|
|
737
880
|
const username = "vscode"
|
|
738
881
|
|
|
@@ -851,23 +994,23 @@ export function buildSetupScript(params: SetupScriptParams): { script: string }
|
|
|
851
994
|
|
|
852
995
|
// ── 4. Dotfiles ───────────────────────────────────────────────────────────
|
|
853
996
|
if (dotfilesRepo) {
|
|
854
|
-
const dotfilesUrl =
|
|
855
|
-
? dotfilesRepo
|
|
856
|
-
: `https://github.com/${dotfilesRepo}`
|
|
997
|
+
const dotfilesUrl = normalizeDotfilesUrl(dotfilesRepo)
|
|
857
998
|
push(...banner("SETUP: DOTFILES"))
|
|
858
999
|
mpAt(mkStatus("dotfiles", allReposPending, pc0, null), "dotfiles")
|
|
859
1000
|
push(
|
|
860
1001
|
`echo "Cloning dotfiles from ${dotfilesUrl}..."`,
|
|
861
1002
|
`set +e`,
|
|
862
|
-
|
|
1003
|
+
// As the user, like every other clone: root has no `~/.ssh`, so a private dotfiles repo
|
|
1004
|
+
// over SSH would authenticate with no key at all.
|
|
1005
|
+
`${asUser(username, `git clone ${shQuote(dotfilesUrl)} ${homeDir}/dotfiles`)} 2>&1`,
|
|
863
1006
|
`_DOTS_EXIT=$?`,
|
|
864
1007
|
`set -e`,
|
|
865
1008
|
`if [ $_DOTS_EXIT -ne 0 ]; then`,
|
|
866
1009
|
` echo "Dotfiles clone failed (exit $_DOTS_EXIT) — continuing without dotfiles"`,
|
|
867
1010
|
`else`,
|
|
868
1011
|
` echo "Dotfiles cloned"`,
|
|
869
|
-
//
|
|
870
|
-
//
|
|
1012
|
+
// The clone above already lands as the user. Kept for a worker whose ~/dotfiles was left
|
|
1013
|
+
// root-owned by an earlier release, where the install script below would fail on it.
|
|
871
1014
|
` chown -R ${username}:${username} ${homeDir}/dotfiles`,
|
|
872
1015
|
` _INSTALL_SCRIPT=""`,
|
|
873
1016
|
` for _candidate in install.sh bootstrap.sh setup.sh script/setup; do`,
|
|
@@ -911,9 +1054,11 @@ export function buildSetupScript(params: SetupScriptParams): { script: string }
|
|
|
911
1054
|
index: i,
|
|
912
1055
|
total: project.repositories.length,
|
|
913
1056
|
homeDir,
|
|
1057
|
+
username,
|
|
914
1058
|
branch: resolveRepoBranch(r, branch),
|
|
915
1059
|
githubInstallationTokens,
|
|
916
1060
|
userSshPrivateKey,
|
|
1061
|
+
userSshKeyRegistered,
|
|
917
1062
|
})
|
|
918
1063
|
push("")
|
|
919
1064
|
push(header)
|
|
@@ -924,11 +1069,10 @@ export function buildSetupScript(params: SetupScriptParams): { script: string }
|
|
|
924
1069
|
})
|
|
925
1070
|
|
|
926
1071
|
// ── 5b. Re-own /workspace after cloning ───────────────────────────────────
|
|
927
|
-
//
|
|
928
|
-
//
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
// node_modules). Must run before postCreate, not just in the final step.
|
|
1072
|
+
// The clones above run as the user, so their checkouts already belong to them. This stays as a
|
|
1073
|
+
// net: a repo left root-owned by an earlier release (when cloning *was* done as root) would
|
|
1074
|
+
// otherwise keep failing postCreate with EACCES — `npm install` on a root-owned node_modules —
|
|
1075
|
+
// and the final chown in step 8 comes too late for that.
|
|
932
1076
|
if (project.repositories.length > 0) {
|
|
933
1077
|
push("", `chown -R ${username}:${username} /workspace`)
|
|
934
1078
|
}
|
|
@@ -1659,7 +1803,7 @@ export function buildSetupPlan(params: SetupScriptParams): {
|
|
|
1659
1803
|
initialStatus: SetupStatus
|
|
1660
1804
|
readyStatus: SetupStatus
|
|
1661
1805
|
} {
|
|
1662
|
-
const { project, userInfo, userSshPrivateKey, dotfilesRepo, userEnvSecrets, githubInstallationTokens, projectDeployKey, branch } = params
|
|
1806
|
+
const { project, userInfo, userSshPrivateKey, userSshKeyRegistered, dotfilesRepo, userEnvSecrets, githubInstallationTokens, projectDeployKey, branch } = params
|
|
1663
1807
|
const homeDir = "/home/vscode"
|
|
1664
1808
|
const username = "vscode"
|
|
1665
1809
|
|
|
@@ -1774,21 +1918,21 @@ export function buildSetupPlan(params: SetupScriptParams): {
|
|
|
1774
1918
|
|
|
1775
1919
|
// ── dotfiles ──────────────────────────────────────────────────────────────
|
|
1776
1920
|
if (dotfilesRepo) {
|
|
1777
|
-
const dotfilesUrl =
|
|
1778
|
-
? dotfilesRepo
|
|
1779
|
-
: `https://github.com/${dotfilesRepo}`
|
|
1921
|
+
const dotfilesUrl = normalizeDotfilesUrl(dotfilesRepo)
|
|
1780
1922
|
const l: string[] = ["set -e", ...banner("SETUP: DOTFILES"),
|
|
1781
1923
|
`echo "Cloning dotfiles from ${dotfilesUrl}..."`,
|
|
1782
1924
|
`set +e`,
|
|
1783
|
-
|
|
1925
|
+
// As the user, like every other clone: root has no `~/.ssh`, so a private dotfiles repo
|
|
1926
|
+
// over SSH would authenticate with no key at all.
|
|
1927
|
+
`${asUser(username, `git clone ${shQuote(dotfilesUrl)} ${homeDir}/dotfiles`)} 2>&1`,
|
|
1784
1928
|
`_DOTS_EXIT=$?`,
|
|
1785
1929
|
`set -e`,
|
|
1786
1930
|
`if [ $_DOTS_EXIT -ne 0 ]; then`,
|
|
1787
1931
|
` echo "Dotfiles clone failed (exit $_DOTS_EXIT) — continuing without dotfiles"`,
|
|
1788
1932
|
`else`,
|
|
1789
1933
|
` echo "Dotfiles cloned"`,
|
|
1790
|
-
//
|
|
1791
|
-
//
|
|
1934
|
+
// The clone above already lands as the user. Kept for a worker whose ~/dotfiles was left
|
|
1935
|
+
// root-owned by an earlier release, where the install script below would fail on it.
|
|
1792
1936
|
` chown -R ${username}:${username} ${homeDir}/dotfiles`,
|
|
1793
1937
|
` _INSTALL_SCRIPT=""`,
|
|
1794
1938
|
` for _candidate in install.sh bootstrap.sh setup.sh script/setup; do`,
|
|
@@ -1833,9 +1977,11 @@ export function buildSetupPlan(params: SetupScriptParams): {
|
|
|
1833
1977
|
index: i,
|
|
1834
1978
|
total: project.repositories.length,
|
|
1835
1979
|
homeDir,
|
|
1980
|
+
username,
|
|
1836
1981
|
branch: resolveRepoBranch(r, branch),
|
|
1837
1982
|
githubInstallationTokens,
|
|
1838
1983
|
userSshPrivateKey,
|
|
1984
|
+
userSshKeyRegistered,
|
|
1839
1985
|
})
|
|
1840
1986
|
l.push("", header, ...cloneLines)
|
|
1841
1987
|
})
|