@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@spunto/build",
3
- "version": "0.5.0",
4
- "description": "Spunto's shared Build engine \u2014 the devcontainer image protocol and VS Code extension registry clients, with no database, no HTTP framework and no UI.",
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 cloneCmd = r.provider === "git" && r.cloneUrl
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} && git -C /workspace/${r.workspacePath} remote set-url origin git@github.com:${r.project}.git`
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
- : `git clone${b} https://github.com/${r.project} /workspace/${r.workspacePath}`
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 = dotfilesRepo.startsWith("http") || dotfilesRepo.startsWith("git@")
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
- `git clone ${JSON.stringify(dotfilesUrl)} ${homeDir}/dotfiles 2>&1`,
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
- // Cloned as root, but the install script below runs as the user (and so does whoever edits
870
- // these files later) — hand the clone over before touching it.
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
- // Repos are cloned as root (the setup script runs as root), so the cloned
928
- // directories end up root-owned. The initial chown (step 1) ran *before* the
929
- // clone, so it didn't cover them. postCreateCommand runs as vscode, so without
930
- // this the user can't write into the repo (e.g. `npm install` → EACCES on
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 = dotfilesRepo.startsWith("http") || dotfilesRepo.startsWith("git@")
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
- `git clone ${JSON.stringify(dotfilesUrl)} ${homeDir}/dotfiles 2>&1`,
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
- // Cloned as root, but the install script below runs as the user (and so does whoever edits
1791
- // these files later) — hand the clone over before touching it.
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
  })