Skip to content

Commit 1a8ba74

Browse files
authored
Merge pull request #13 from 1to10partners/fix/ubuntu26-gstack-provisioning
Fix gstack setup on Ubuntu 26 Sprites
2 parents 6620cc4 + a46d55a commit 1a8ba74

3 files changed

Lines changed: 394 additions & 26 deletions

File tree

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ cd /path/to/your/repo
4444
seven up
4545
```
4646

47-
On first run, `seven up` will prompt you to run `sprite login`, then create the sprite, and finally clone your repo and handle basic git setup. If host `codex` is logged in using ChatGPT, `seven init` also copies `~/.codex/auth.json` into the sprite so Codex is authenticated there. After clone, `seven init` configures one-shot console bootstrap for both Bash and Zsh so the first `sprite console` opens in the cloned repo and starts `codex`. On each `seven up`, the host `sprite` CLI is checked for updates and auto-upgraded when a newer version is available. Subsequent runs skip `init` and are therefore instant.
47+
On first run, `seven up` will prompt you to run `sprite login`, then create the sprite, and finally clone your repo and handle basic git setup. If host `codex` is logged in using ChatGPT, `seven init` also copies `~/.codex/auth.json` into the sprite so Codex is authenticated there. After clone, `seven init` configures one-shot console bootstrap for Bash, Zsh, and fish so the first `sprite console` opens in the cloned repo and suggests the selected assistant. Use `seven up --assistant codex` or `seven up --assistant claude` when both credentials exist and you want a deterministic choice. On each `seven up`, the host `sprite` CLI is checked for updates and auto-upgraded when a newer version is available. Subsequent runs skip `init` and are therefore instant.
4848

4949
Once inside the sprite, cd into your folder and start your favorite assistant. The following come pre-installed: `claude`, `codex`, `cursor-agent`, and `gemini-cli`.
5050

@@ -74,9 +74,10 @@ npm vercel vercel@54.12.2 vercel --version
7474
pip ruff ruff==0.15.18 ruff --version
7575
pip-module pynacl pynacl==1.6.2 nacl 1.6.2
7676
archive flyctl <version>|<https-url>|<sha-x86>|<sha-arm>|flyctl|fly flyctl version
77+
archive shellcheck <version>|<https-url>|<sha-x86>|<sha-arm>|release-dir/shellcheck|- shellcheck --version
7778
```
7879

79-
Install is **idempotent**, exact-version verified, and fail-closed: every declared row is required, so a failed reconciliation blocks the console instead of presenting a partially provisioned Sprite. Archive rows require per-architecture SHA-256 checksums. A `gstack` row requires an immutable commit; Seven fetches it from the official origin into a fresh staging repository, atomically replaces the old checkout, and registers it for every supported assistant found in the Sprite. Repos without a manifest are unaffected. (Secrets are *not* handled here — tooling install only; credentials are a separate, project-owned concern.)
80+
Install is **idempotent**, exact-version verified, and fail-closed: every declared row is required, so a failed reconciliation blocks the console instead of presenting a partially provisioned Sprite. Archive rows require per-architecture SHA-256 checksums and may select a safe nested member. URL templates use `{arch}` (`x86_64`/`arm64`) or `{gnuarch}` (`x86_64`/`aarch64`). A `gstack` row requires an immutable commit; Seven fetches it from the official origin into a fresh staging repository, atomically replaces the old checkout, and registers it for every supported assistant found in the Sprite. Repos without a manifest are unaffected. (Secrets are *not* handled here — tooling install only; credentials are a separate, project-owned concern.)
8081

8182
### Assistant authentication
8283
Host assistant credentials are copied into the sprite **once, at creation**, mirroring how `gh` auth is bootstrapped. Subsequent `seven up`s do not re-sync — re-running config + auth uploads for both assistants on every reconnect added noticeable latency without changing the result for a working sprite.
@@ -86,6 +87,8 @@ What's synced on the initial `seven up`:
8687
- **Claude Code:** seven syncs the real OAuth credential store, not just `~/.claude.json`. On Linux that's `~/.claude/.credentials.json`; on macOS the tokens live in the login Keychain (service `claude-code` / `Claude Code-credentials`), which seven extracts and writes into the sprite. (The Keychain read may show a one-time access prompt.) `~/.claude/settings.json` and `~/.claude.json` are deep-merged so sprite-only keys are preserved.
8788
- **Codex:** `~/.codex/auth.json` and `~/.codex/config.toml` are copied in.
8889

90+
Seven copies both assistants' available credentials regardless of which one is selected. Without an explicit `--assistant`, it preserves the existing auto-detection behavior; pass `--assistant codex` or `--assistant claude` to choose the console hint deterministically.
91+
8992
If a host token rotates and the sprite copy goes stale, run `claude` (or `codex login`) **inside the sprite** to re-auth — the same recovery path used for `gh`. Note that the host and a sprite share one refresh token, so a refresh on one side can occasionally invalidate the other ("token has already been used"); the fix is the same in-sprite re-login.
9093

9194
### Uninstall
@@ -137,6 +140,8 @@ seven up --gstack
137140

138141
The Sprite image must already provide Bun; Seven never bootstraps it with a remote shell script. Seven fetches and verifies an immutable gstack commit, cleans ignored build/dependency artifacts, installs its frozen lockfile, then runs `./setup --host auto --no-team`. Disabling team mode is intentional: its auto-updater would defeat the manifest pin. The same checkout registers skills for Claude Code, Codex, and any other supported host found in the Sprite. Re-running `seven up` reconciles a project-required install, including the browser payload.
139142

143+
Sprite images newer than Playwright's recognized Ubuntu matrix use Playwright's supported Ubuntu 24.04 compatibility build during setup. The override is scoped to that setup process and leaves recognized operating systems untouched.
144+
140145
## Features
141146
- **Core CLI:** `seven init`, `seven up`, `seven destroy`, `seven status`, `seven list`.
142147
- **gstack:** optional `--gstack` install of the gstack skill toolkit into the sprite.

cmd/seven/main.go

Lines changed: 109 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type upOptions struct {
3232
QuietExternal bool
3333
AssumeLoggedIn bool
3434
OpenConsole bool
35+
Assistant string
3536
SpriteName string
3637
NewSprite bool
3738
ResolvedName string
@@ -88,6 +89,25 @@ const (
8889
// Keep the default used by the explicit --gstack flag immutable. Projects
8990
// may request another immutable revision in their tooling manifest.
9091
gstackDefaultRevision = "a3259400a366593e0c909dd9ac3e59752efd2488"
92+
// Playwright in the pinned gstack revision recognizes Ubuntu through 24.04.
93+
// Sprite images currently report Ubuntu 26.04, whose Chromium build is ABI-
94+
// compatible with Playwright's Ubuntu 24.04 payload but has no registry key.
95+
// Apply Playwright's supported host override only for Ubuntu 26+ and only
96+
// during gstack setup; leave every recognized platform untouched.
97+
gstackPlaywrightPlatformCmd = `if [ "$(uname -s)" = "Linux" ] && [ -z "${PLAYWRIGHT_HOST_PLATFORM_OVERRIDE:-}" ] && [ -r /etc/os-release ]; then
98+
. /etc/os-release
99+
ubuntu_major="${VERSION_ID%%.*}"
100+
if [ "${ID:-}" = "ubuntu" ] && case "$ubuntu_major" in ''|*[!0-9]*) false ;; *) [ "$ubuntu_major" -ge 26 ] ;; esac; then
101+
case "$(uname -m)" in
102+
x86_64|amd64) PLAYWRIGHT_HOST_PLATFORM_OVERRIDE="ubuntu24.04-x64" ;;
103+
aarch64|arm64) PLAYWRIGHT_HOST_PLATFORM_OVERRIDE="ubuntu24.04-arm64" ;;
104+
esac
105+
if [ -n "${PLAYWRIGHT_HOST_PLATFORM_OVERRIDE:-}" ]; then
106+
export PLAYWRIGHT_HOST_PLATFORM_OVERRIDE
107+
echo "[seven] using Playwright $PLAYWRIGHT_HOST_PLATFORM_OVERRIDE compatibility build for Ubuntu $VERSION_ID"
108+
fi
109+
fi
110+
fi`
91111
// gstackChromiumDepsCmd installs the OS shared libraries Chromium links
92112
// against (libglib-2.0, libnss3, libgbm, …). gstack's ./setup downloads the
93113
// browser *binary* but not these system libs, so on a minimal sprite image
@@ -144,8 +164,8 @@ func usage() {
144164
fmt.Printf("version: %s\n", version)
145165
fmt.Println()
146166
fmt.Println("Usage:")
147-
fmt.Println(" seven init [--assume-logged-in] [--new] [--sprite name] [--gstack]")
148-
fmt.Println(" seven up [N] [--assume-logged-in] [--new] [--sprite name] [--no-console] [--no-tui] [--gstack]")
167+
fmt.Println(" seven init [--assume-logged-in] [--new] [--sprite name] [--assistant codex|claude] [--gstack]")
168+
fmt.Println(" seven up [N] [--assume-logged-in] [--new] [--sprite name] [--assistant codex|claude] [--no-console] [--no-tui] [--gstack]")
149169
fmt.Println(" seven destroy [name] [--sprite name]")
150170
fmt.Println(" seven status")
151171
fmt.Println(" seven list")
@@ -165,13 +185,24 @@ func printVersion() {
165185
fmt.Println(version)
166186
}
167187

188+
func normalizeAssistant(value string) (string, error) {
189+
value = strings.ToLower(strings.TrimSpace(value))
190+
switch value {
191+
case "", "codex", "claude":
192+
return value, nil
193+
default:
194+
return "", fmt.Errorf("unsupported assistant %q (use codex or claude)", value)
195+
}
196+
}
197+
168198
func cmdUp(args []string) {
169199
fs := flag.NewFlagSet("up", flag.ExitOnError)
170200
noTUI := fs.Bool("no-tui", false, "disable TUI output")
171201
assumeLoggedIn := fs.Bool("assume-logged-in", false, "skip sprite login")
172202
noConsole := fs.Bool("no-console", false, "do not open sprite console after up")
173203
newSprite := fs.Bool("new", false, "create and select a new sibling sprite")
174204
spriteName := fs.String("sprite", "", "use a specific sprite name")
205+
assistant := fs.String("assistant", "", "preferred assistant: codex or claude")
175206
gstack := fs.Bool("gstack", false, "install gstack (github.com/garrytan/gstack) into the sprite")
176207

177208
// An optional leading number (e.g. "seven up 2") selects sibling #N. It must
@@ -197,6 +228,11 @@ func cmdUp(args []string) {
197228
fmt.Fprintln(os.Stderr, "seven up failed: sprite number cannot be combined with --new or --sprite")
198229
os.Exit(1)
199230
}
231+
preferredAssistant, err := normalizeAssistant(*assistant)
232+
if err != nil {
233+
fmt.Fprintf(os.Stderr, "seven up failed: %v\n", err)
234+
os.Exit(1)
235+
}
200236

201237
shouldUseTUI := !*noTUI
202238
styleEnabled = shouldUseTUI
@@ -205,6 +241,7 @@ func cmdUp(args []string) {
205241
QuietExternal: false,
206242
AssumeLoggedIn: *assumeLoggedIn,
207243
OpenConsole: !*noConsole,
244+
Assistant: preferredAssistant,
208245
SpriteName: strings.TrimSpace(*spriteName),
209246
NewSprite: *newSprite,
210247
InstallGstack: *gstack,
@@ -243,18 +280,25 @@ func cmdInit(args []string) {
243280
assumeLoggedIn := fs.Bool("assume-logged-in", false, "skip sprite login")
244281
newSprite := fs.Bool("new", false, "create and select a new sibling sprite")
245282
spriteName := fs.String("sprite", "", "use a specific sprite name")
283+
assistant := fs.String("assistant", "", "preferred assistant: codex or claude")
246284
gstack := fs.Bool("gstack", false, "install gstack (github.com/garrytan/gstack) into the sprite")
247285
_ = fs.Parse(args)
248286
if *newSprite && strings.TrimSpace(*spriteName) != "" {
249287
fmt.Fprintln(os.Stderr, "seven init failed: --new and --sprite cannot be used together")
250288
os.Exit(1)
251289
}
290+
preferredAssistant, err := normalizeAssistant(*assistant)
291+
if err != nil {
292+
fmt.Fprintf(os.Stderr, "seven init failed: %v\n", err)
293+
os.Exit(1)
294+
}
252295

253-
_, err := runInit(upOptions{
296+
_, err = runInit(upOptions{
254297
Logger: func(msg string) { fmt.Println(msg) },
255298
QuietExternal: false,
256299
AssumeLoggedIn: *assumeLoggedIn,
257300
OpenConsole: false,
301+
Assistant: preferredAssistant,
258302
SpriteName: strings.TrimSpace(*spriteName),
259303
NewSprite: *newSprite,
260304
InstallGstack: *gstack,
@@ -1012,7 +1056,7 @@ func maybeUpgradeSpriteCLI(opts upOptions) {
10121056
opts.Logger("[seven up] could not parse sprite upgrade check output; skipping auto-upgrade")
10131057
return
10141058
}
1015-
if latest == current {
1059+
if spriteVersionsEqual(latest, current) {
10161060
opts.Logger(fmt.Sprintf("[seven up] sprite CLI is up to date (%s)", current))
10171061
return
10181062
}
@@ -1030,6 +1074,10 @@ func maybeUpgradeSpriteCLI(opts upOptions) {
10301074
opts.Logger(fmt.Sprintf("[seven up] sprite CLI upgraded to %s", latest))
10311075
}
10321076

1077+
func spriteVersionsEqual(left, right string) bool {
1078+
return strings.TrimPrefix(left, "v") == strings.TrimPrefix(right, "v")
1079+
}
1080+
10331081
func parseSpriteUpgradeCheckOutput(out string) (latest, current string, ok bool) {
10341082
cleaned := ansiEscapeRe.ReplaceAllString(out, "")
10351083
latestMatch := spriteLatestVersionPattern.FindStringSubmatch(cleaned)
@@ -1167,6 +1215,7 @@ gstack_staging=""
11671215
rm -rf "$gstack_backup"
11681216
cd "` + gstackSkillDir + `"
11691217
bun install --frozen-lockfile
1218+
` + gstackPlaywrightPlatformCmd + `
11701219
` + gstackChromiumDepsCmd + `
11711220
./setup --host auto --no-team`
11721221
if out, err := spriteExecOutput(spriteName, nil, "sh", "-lc", install); err != nil {
@@ -1323,9 +1372,9 @@ func parseProjectToolingManifest(contents string) (validatedToolingManifest, err
13231372
}
13241373
parts := strings.Split(packageSpec, "|")
13251374
if len(parts) != 6 || !toolingVersionPattern.MatchString(parts[0]) ||
1326-
!strings.HasPrefix(parts[1], "https://") || strings.Count(parts[1], "{arch}") != 1 ||
1375+
!validArchiveURLTemplate(parts[1]) ||
13271376
!sha256Pattern.MatchString(parts[2]) || !sha256Pattern.MatchString(parts[3]) ||
1328-
!toolingNamePattern.MatchString(parts[4]) {
1377+
!validArchiveMember(parts[4]) {
13291378
return validatedToolingManifest{}, fmt.Errorf("invalid project tooling manifest line %d: malformed archive spec", index+1)
13301379
}
13311380
for _, alias := range strings.Split(parts[5], ",") {
@@ -1346,6 +1395,27 @@ func parseProjectToolingManifest(contents string) (validatedToolingManifest, err
13461395
return manifest, nil
13471396
}
13481397

1398+
func validArchiveURLTemplate(value string) bool {
1399+
if !strings.HasPrefix(value, "https://") ||
1400+
strings.Count(value, "{arch}")+strings.Count(value, "{gnuarch}") != 1 {
1401+
return false
1402+
}
1403+
withoutKnown := strings.ReplaceAll(strings.ReplaceAll(value, "{arch}", ""), "{gnuarch}", "")
1404+
return !strings.ContainsAny(withoutKnown, "{}")
1405+
}
1406+
1407+
func validArchiveMember(value string) bool {
1408+
if value == "" || strings.HasPrefix(value, "/") {
1409+
return false
1410+
}
1411+
for _, component := range strings.Split(value, "/") {
1412+
if !toolingNamePattern.MatchString(component) {
1413+
return false
1414+
}
1415+
}
1416+
return true
1417+
}
1418+
13491419
// reconcileProjectEnvironment is the single provisioning path for new and
13501420
// existing sprites. Every seven up repairs missing gstack/browser artifacts and
13511421
// reruns Seven's typed pinned-tool reconciler.
@@ -1413,30 +1483,47 @@ install_archive() {
14131483
archive_name="$1" archive_spec="$2"
14141484
old_ifs="$IFS"; IFS='|'; set -f; set -- $archive_spec; set +f; IFS="$old_ifs"
14151485
[ "$#" -eq 6 ] || return 1
1416-
archive_version="$1" url_template="$2" sha_x86="$3" sha_arm="$4" archive_binary="$5" archive_aliases="$6"
1486+
archive_version="$1" url_template="$2" sha_x86="$3" sha_arm="$4" archive_member="$5" archive_aliases="$6"
14171487
case "$url_template" in https://*) ;; *) return 1 ;; esac
14181488
case "$sha_x86$sha_arm" in *[!0-9a-f]*) return 1 ;; esac
14191489
[ "${#sha_x86}" -eq 64 ] && [ "${#sha_arm}" -eq 64 ] || return 1
1420-
case "$archive_binary$archive_aliases" in *[!A-Za-z0-9._,-]*) return 1 ;; esac
1490+
case "$archive_member" in ''|/*|*//*|*[!A-Za-z0-9._/-]*) return 1 ;; esac
1491+
old_ifs="$IFS"; IFS='/'; set -f; set -- $archive_member; set +f; IFS="$old_ifs"
1492+
for archive_component in "$@"; do
1493+
case "$archive_component" in [A-Za-z0-9]*) ;; *) return 1 ;; esac
1494+
done
1495+
case "$archive_aliases" in *[!A-Za-z0-9._,-]*) return 1 ;; esac
14211496
case "$(uname -m)" in
1422-
x86_64|amd64) archive_arch="x86_64"; archive_sha="$sha_x86" ;;
1423-
aarch64|arm64) archive_arch="arm64"; archive_sha="$sha_arm" ;;
1497+
x86_64|amd64) archive_arch="x86_64"; archive_gnuarch="x86_64"; archive_sha="$sha_x86" ;;
1498+
aarch64|arm64) archive_arch="arm64"; archive_gnuarch="aarch64"; archive_sha="$sha_arm" ;;
14241499
*) return 1 ;;
14251500
esac
1426-
archive_url="$(printf '%s' "$url_template" | sed "s/{arch}/$archive_arch/g")"
1501+
archive_url="$(printf '%s' "$url_template" | sed "s/{arch}/$archive_arch/g; s/{gnuarch}/$archive_gnuarch/g")"
14271502
archive_tmp="$(mktemp -d)" || return 1
1503+
archive_extracted="$archive_tmp/extracted"
14281504
if ! curl -fsSL "$archive_url" -o "$archive_tmp/archive.tgz" ||
14291505
! printf '%s %s\n' "$archive_sha" "$archive_tmp/archive.tgz" | sha256sum -c - >/dev/null 2>&1 ||
1430-
! tar -xzf "$archive_tmp/archive.tgz" -C "$archive_tmp" "$archive_binary" >/dev/null 2>&1; then
1506+
! archive_listing="$(tar -tvzf "$archive_tmp/archive.tgz" -- "$archive_member" 2>/dev/null)" ||
1507+
[ "$(printf '%s\n' "$archive_listing" | wc -l | tr -d ' ')" != 1 ] ||
1508+
[ "${archive_listing#-}" = "$archive_listing" ] ||
1509+
! tar -xOzf "$archive_tmp/archive.tgz" -- "$archive_member" > "$archive_extracted" 2>/dev/null ||
1510+
[ ! -f "$archive_extracted" ]; then
14311511
rm -rf "$archive_tmp"; return 1
14321512
fi
14331513
mkdir -p "$HOME/.local/bin" || { rm -rf "$archive_tmp"; return 1; }
1434-
install -m 0755 "$archive_tmp/$archive_binary" "$HOME/.local/bin/$archive_name" || {
1514+
old_ifs="$IFS"; IFS=','; set -f; set -- $archive_aliases; set +f; IFS="$old_ifs"
1515+
for archive_alias in "$@"; do
1516+
if [ "$archive_alias" != "-" ] && [ -d "$HOME/.local/bin/$archive_alias" ] && [ ! -L "$HOME/.local/bin/$archive_alias" ]; then
1517+
rm -rf "$archive_tmp"; return 1
1518+
fi
1519+
done
1520+
install -m 0755 "$archive_extracted" "$HOME/.local/bin/$archive_name" || {
14351521
rm -rf "$archive_tmp"; return 1;
14361522
}
1437-
old_ifs="$IFS"; IFS=','; set -f; set -- $archive_aliases; set +f; IFS="$old_ifs"
14381523
for archive_alias in "$@"; do
1439-
[ "$archive_alias" = "-" ] || ln -sf "$archive_name" "$HOME/.local/bin/$archive_alias" || {
1524+
[ "$archive_alias" = "-" ] ||
1525+
{ ln -sfn "$archive_name" "$HOME/.local/bin/$archive_alias" &&
1526+
[ "$(readlink "$HOME/.local/bin/$archive_alias")" = "$archive_name" ]; } || {
14401527
rm -rf "$archive_tmp"; return 1;
14411528
}
14421529
done
@@ -1764,6 +1851,10 @@ func detectHostAssistantState(opts upOptions) hostAssistantState {
17641851
state.ClaudeCredentials = detectHostClaudeCredentials(opts)
17651852
state.CodexAuthPath = detectHostCodexChatGPTAuth(opts)
17661853
state.CodexConfigPath = detectHostCodexConfig(opts)
1854+
if opts.Assistant != "" {
1855+
state.PreferredAssistant = opts.Assistant
1856+
return state
1857+
}
17671858

17681859
switch {
17691860
case state.ClaudeAuthPath != "":
@@ -1797,6 +1888,9 @@ func syncHostAssistantState(spriteName string, state hostAssistantState, phase s
17971888
}
17981889

17991890
func resolvePreferredAssistantInSprite(spriteName string, state hostAssistantState, phase string, opts upOptions) string {
1891+
if opts.Assistant != "" {
1892+
return opts.Assistant
1893+
}
18001894
if loggedIn, err := spriteClaudeLoggedIn(spriteName); err != nil {
18011895
opts.Logger(fmt.Sprintf("%s claude auth validation failed: %v", phase, err))
18021896
} else if loggedIn {

0 commit comments

Comments
 (0)