@spunto/build 0.5.0 → 0.5.1

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.5.1",
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,13 @@ 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
111
144
  }): { header: string; lines: string[] } {
112
- const { repo: r, index, total, homeDir, branch, githubInstallationTokens, userSshPrivateKey } = params
145
+ const { repo: r, index, total, homeDir, username, branch, githubInstallationTokens, userSshPrivateKey } = params
113
146
  // Pick the installation token for this repo's owner. A GitHub App installation only grants
114
147
  // access to repos owned by its account, so the owner login (== installation accountLogin)
115
148
  // uniquely selects the right token among the org's connected installations. Covers legacy
@@ -117,13 +150,16 @@ function cloneRepoBlock(params: {
117
150
  const owner = (r.project.split("/")[0] ?? "").toLowerCase()
118
151
  const repoToken = githubInstallationTokens?.[owner]
119
152
  const b = branch ? ` --branch ${shQuote(branch)}` : ""
120
- const cloneCmd = r.provider === "git" && r.cloneUrl
153
+ const rawCloneCmd = r.provider === "git" && r.cloneUrl
121
154
  ? `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
155
  : r.provider === "github" && repoToken
123
156
  ? `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`
124
157
  : r.provider === "github" && userSshPrivateKey
125
158
  ? `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
159
  : `git clone${b} https://github.com/${r.project} /workspace/${r.workspacePath}`
160
+ // Cloned as the user, not as root: /workspace is already theirs (the ownership step runs
161
+ // first), so the checkout lands with the right owner and needs no chown afterwards.
162
+ const cloneCmd = asUser(username, rawCloneCmd)
127
163
 
128
164
  // Single-quoted only when a (user-provided) branch is interpolated; the plain form is kept
129
165
  // verbatim for the no-branch case so the generated script is unchanged there.
@@ -851,23 +887,23 @@ export function buildSetupScript(params: SetupScriptParams): { script: string }
851
887
 
852
888
  // ── 4. Dotfiles ───────────────────────────────────────────────────────────
853
889
  if (dotfilesRepo) {
854
- const dotfilesUrl = dotfilesRepo.startsWith("http") || dotfilesRepo.startsWith("git@")
855
- ? dotfilesRepo
856
- : `https://github.com/${dotfilesRepo}`
890
+ const dotfilesUrl = normalizeDotfilesUrl(dotfilesRepo)
857
891
  push(...banner("SETUP: DOTFILES"))
858
892
  mpAt(mkStatus("dotfiles", allReposPending, pc0, null), "dotfiles")
859
893
  push(
860
894
  `echo "Cloning dotfiles from ${dotfilesUrl}..."`,
861
895
  `set +e`,
862
- `git clone ${JSON.stringify(dotfilesUrl)} ${homeDir}/dotfiles 2>&1`,
896
+ // As the user, like every other clone: root has no `~/.ssh`, so a private dotfiles repo
897
+ // over SSH would authenticate with no key at all.
898
+ `${asUser(username, `git clone ${shQuote(dotfilesUrl)} ${homeDir}/dotfiles`)} 2>&1`,
863
899
  `_DOTS_EXIT=$?`,
864
900
  `set -e`,
865
901
  `if [ $_DOTS_EXIT -ne 0 ]; then`,
866
902
  ` echo "Dotfiles clone failed (exit $_DOTS_EXIT) — continuing without dotfiles"`,
867
903
  `else`,
868
904
  ` 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.
905
+ // The clone above already lands as the user. Kept for a worker whose ~/dotfiles was left
906
+ // root-owned by an earlier release, where the install script below would fail on it.
871
907
  ` chown -R ${username}:${username} ${homeDir}/dotfiles`,
872
908
  ` _INSTALL_SCRIPT=""`,
873
909
  ` for _candidate in install.sh bootstrap.sh setup.sh script/setup; do`,
@@ -911,6 +947,7 @@ export function buildSetupScript(params: SetupScriptParams): { script: string }
911
947
  index: i,
912
948
  total: project.repositories.length,
913
949
  homeDir,
950
+ username,
914
951
  branch: resolveRepoBranch(r, branch),
915
952
  githubInstallationTokens,
916
953
  userSshPrivateKey,
@@ -924,11 +961,10 @@ export function buildSetupScript(params: SetupScriptParams): { script: string }
924
961
  })
925
962
 
926
963
  // ── 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.
964
+ // The clones above run as the user, so their checkouts already belong to them. This stays as a
965
+ // net: a repo left root-owned by an earlier release (when cloning *was* done as root) would
966
+ // otherwise keep failing postCreate with EACCES — `npm install` on a root-owned node_modules —
967
+ // and the final chown in step 8 comes too late for that.
932
968
  if (project.repositories.length > 0) {
933
969
  push("", `chown -R ${username}:${username} /workspace`)
934
970
  }
@@ -1774,21 +1810,21 @@ export function buildSetupPlan(params: SetupScriptParams): {
1774
1810
 
1775
1811
  // ── dotfiles ──────────────────────────────────────────────────────────────
1776
1812
  if (dotfilesRepo) {
1777
- const dotfilesUrl = dotfilesRepo.startsWith("http") || dotfilesRepo.startsWith("git@")
1778
- ? dotfilesRepo
1779
- : `https://github.com/${dotfilesRepo}`
1813
+ const dotfilesUrl = normalizeDotfilesUrl(dotfilesRepo)
1780
1814
  const l: string[] = ["set -e", ...banner("SETUP: DOTFILES"),
1781
1815
  `echo "Cloning dotfiles from ${dotfilesUrl}..."`,
1782
1816
  `set +e`,
1783
- `git clone ${JSON.stringify(dotfilesUrl)} ${homeDir}/dotfiles 2>&1`,
1817
+ // As the user, like every other clone: root has no `~/.ssh`, so a private dotfiles repo
1818
+ // over SSH would authenticate with no key at all.
1819
+ `${asUser(username, `git clone ${shQuote(dotfilesUrl)} ${homeDir}/dotfiles`)} 2>&1`,
1784
1820
  `_DOTS_EXIT=$?`,
1785
1821
  `set -e`,
1786
1822
  `if [ $_DOTS_EXIT -ne 0 ]; then`,
1787
1823
  ` echo "Dotfiles clone failed (exit $_DOTS_EXIT) — continuing without dotfiles"`,
1788
1824
  `else`,
1789
1825
  ` 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.
1826
+ // The clone above already lands as the user. Kept for a worker whose ~/dotfiles was left
1827
+ // root-owned by an earlier release, where the install script below would fail on it.
1792
1828
  ` chown -R ${username}:${username} ${homeDir}/dotfiles`,
1793
1829
  ` _INSTALL_SCRIPT=""`,
1794
1830
  ` for _candidate in install.sh bootstrap.sh setup.sh script/setup; do`,
@@ -1833,6 +1869,7 @@ export function buildSetupPlan(params: SetupScriptParams): {
1833
1869
  index: i,
1834
1870
  total: project.repositories.length,
1835
1871
  homeDir,
1872
+ username,
1836
1873
  branch: resolveRepoBranch(r, branch),
1837
1874
  githubInstallationTokens,
1838
1875
  userSshPrivateKey,