diff --git a/bin/gstack-artifacts-init b/bin/gstack-artifacts-init new file mode 100755 index 00000000..e3b17cf4 --- /dev/null +++ b/bin/gstack-artifacts-init @@ -0,0 +1,387 @@ +#!/usr/bin/env bash +# gstack-artifacts-init — set up ~/.gstack/ as a git repo synced to a private +# git host (GitHub or GitLab) so a remote gbrain can ingest your artifacts +# (CEO plans, designs, /investigate reports) as a federated source. +# +# Replaces gstack-brain-init in v1.27.0.0 (per D4 hard-delete; no compat +# shim). Existing users are migrated by gstack-upgrade/migrations/v1.27.0.0.sh. +# +# Usage: +# gstack-artifacts-init [--remote ] [--host github|gitlab|manual] +# [--url-form-supported true|false] +# +# Interactive by default. Pass --remote to skip the host prompt. +# +# Idempotent: safe to re-run. If ~/.gstack/.git already exists AND points at +# the same remote, reconfigures drivers/hooks/attributes without clobbering +# history. If it points at a DIFFERENT remote, refuses. +# +# What it does: +# 1. git init ~/.gstack/ (or verify existing repo points at the right remote) +# 2. Write .gitignore = "*" (ignore everything; allowlist is explicit) +# 3. Write .brain-allowlist (canonical paths to sync) +# 4. Write .brain-privacy-map.json (paths → privacy class) +# 5. Write .gitattributes (register JSONL + union merge drivers) +# 6. git config merge.jsonl-append.driver + merge.union.driver +# 7. Install .git/hooks/pre-commit (defense-in-depth secret scan) +# 8. Provider-aware repo create (gh / glab) OR manual URL paste +# 9. Initial commit + push +# 10. Write ~/.gstack-artifacts-remote.txt (HTTPS URL — canonical form) +# 11. Print "Send this to your brain admin" hookup command +# +# Env: +# GSTACK_HOME — override ~/.gstack +# USER — fallback for repo naming if $USER is unset + +set -euo pipefail + +GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +URL_BIN="$SCRIPT_DIR/gstack-artifacts-url" +REMOTE_FILE="$HOME/.gstack-artifacts-remote.txt" + +REMOTE_URL="" +HOST_PREF="" +URL_FORM_SUPPORTED="false" +while [ $# -gt 0 ]; do + case "$1" in + --remote) REMOTE_URL="$2"; shift 2 ;; + --host) HOST_PREF="$2"; shift 2 ;; + --url-form-supported) URL_FORM_SUPPORTED="$2"; shift 2 ;; + --help|-h) sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "Unknown flag: $1" >&2; exit 1 ;; + esac +done + +# ---- preconditions ---- +mkdir -p "$GSTACK_HOME" + +EXISTING_REMOTE="" +if [ -d "$GSTACK_HOME/.git" ]; then + EXISTING_REMOTE=$(git -C "$GSTACK_HOME" remote get-url origin 2>/dev/null || echo "") + if [ -n "$EXISTING_REMOTE" ] && [ -n "$REMOTE_URL" ]; then + # Compare at the canonical level. The stored remote is SSH (for git push), + # the input is usually HTTPS — same logical repo, different surface form. + EXISTING_HTTPS=$("$URL_BIN" --to https "$EXISTING_REMOTE" 2>/dev/null || echo "$EXISTING_REMOTE") + INPUT_HTTPS=$("$URL_BIN" --to https "$REMOTE_URL" 2>/dev/null || echo "$REMOTE_URL") + if [ "$EXISTING_HTTPS" != "$INPUT_HTTPS" ]; then + cat >&2 < +EOF + exit 1 + fi + fi +fi + +# ---- detect available providers ---- +gh_ok=false +glab_ok=false +if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then gh_ok=true; fi +if command -v glab >/dev/null 2>&1 && glab auth status >/dev/null 2>&1; then glab_ok=true; fi + +# ---- choose remote URL ---- +if [ -z "$REMOTE_URL" ] && [ -n "$EXISTING_REMOTE" ]; then + REMOTE_URL="$EXISTING_REMOTE" + echo "Using existing remote: $REMOTE_URL" +fi + +REPO_NAME="gstack-artifacts-${USER:-$(whoami)}" +DESCRIPTION="gstack artifacts (CEO plans, designs, reports) — synced from ~/.gstack/projects/" + +# Decide host preference if not pinned by --host. +if [ -z "$REMOTE_URL" ] && [ -z "$HOST_PREF" ]; then + if $gh_ok && $glab_ok; then + cat >&2 <&2 + read -r CH || CH="" + case "$CH" in + ""|1) HOST_PREF="github" ;; + 2) HOST_PREF="gitlab" ;; + 3) HOST_PREF="manual" ;; + *) echo "Invalid choice: $CH" >&2; exit 1 ;; + esac + elif $gh_ok; then + HOST_PREF="github" + echo "Using GitHub (gh CLI authenticated; glab not available)" >&2 + elif $glab_ok; then + HOST_PREF="gitlab" + echo "Using GitLab (glab CLI authenticated; gh not available)" >&2 + else + HOST_PREF="manual" + echo "(Neither gh nor glab CLI authenticated — falling through to manual URL)" >&2 + fi +fi + +# ---- create repo on chosen host ---- +if [ -z "$REMOTE_URL" ]; then + case "$HOST_PREF" in + github) + echo "Creating GitHub repo: $REPO_NAME ..." + if ! gh repo create "$REPO_NAME" --private --description "$DESCRIPTION" 2>/dev/null; then + # Maybe already exists; try to fetch its URL. + REMOTE_URL=$(gh repo view "$REPO_NAME" --json url -q .url 2>/dev/null || echo "") + if [ -z "$REMOTE_URL" ]; then + echo "Failed to create or find '$REPO_NAME'. Try --remote ." >&2 + exit 1 + fi + echo "Repo already exists; using $REMOTE_URL" + else + REMOTE_URL=$(gh repo view "$REPO_NAME" --json url -q .url 2>/dev/null || echo "") + fi + ;; + gitlab) + echo "Creating GitLab repo: $REPO_NAME ..." + if ! glab repo create "$REPO_NAME" --private --description "$DESCRIPTION" 2>/dev/null; then + REMOTE_URL=$(glab repo view "$REPO_NAME" -F json 2>/dev/null | jq -r '.web_url // empty' 2>/dev/null || echo "") + if [ -z "$REMOTE_URL" ]; then + echo "Failed to create or find '$REPO_NAME'. Try --remote ." >&2 + exit 1 + fi + echo "Repo already exists; using $REMOTE_URL" + else + REMOTE_URL=$(glab repo view "$REPO_NAME" -F json 2>/dev/null | jq -r '.web_url // empty' 2>/dev/null || echo "") + fi + ;; + manual) + echo "(provide a private git URL)" + printf "Paste an HTTPS git URL (e.g. https://github.com/you/gstack-artifacts.git): " >&2 + read -r REMOTE_URL || REMOTE_URL="" + if [ -z "$REMOTE_URL" ]; then + echo "No URL provided. Aborting." >&2 + exit 1 + fi + ;; + *) echo "Unknown --host: $HOST_PREF (expected github|gitlab|manual)" >&2; exit 1 ;; + esac +fi + +# ---- canonicalize to HTTPS form ---- +# We store HTTPS in ~/.gstack-artifacts-remote.txt (codex Finding #10: +# canonical form, derive SSH at push time via gstack-artifacts-url --to ssh). +CANONICAL_HTTPS=$("$URL_BIN" --to https "$REMOTE_URL" 2>/dev/null || echo "") +if [ -z "$CANONICAL_HTTPS" ]; then + echo "Failed to canonicalize remote URL: $REMOTE_URL" >&2 + exit 1 +fi + +# Use SSH for git push (more reliable for repeated pushes than HTTPS+token). +PUSH_URL=$("$URL_BIN" --to ssh "$CANONICAL_HTTPS") + +# ---- verify push URL is reachable ---- +echo "Verifying remote connectivity: $PUSH_URL" +if ! git ls-remote "$PUSH_URL" >/dev/null 2>&1; then + cat >&2 </dev/null || git -C "$GSTACK_HOME" init -q + git -C "$GSTACK_HOME" branch -M main 2>/dev/null || true +fi + +if [ -z "$(git -C "$GSTACK_HOME" remote 2>/dev/null)" ]; then + git -C "$GSTACK_HOME" remote add origin "$PUSH_URL" +else + git -C "$GSTACK_HOME" remote set-url origin "$PUSH_URL" +fi + +# ---- write canonical files (idempotent) ---- +cat > "$GSTACK_HOME/.gitignore" <<'EOF' +# gstack-artifacts sync: ignore-everything base. Paths are included explicitly via +# .brain-allowlist and `git add -f` from gstack-brain-sync. Do not edit. +* +EOF + +cat > "$GSTACK_HOME/.brain-allowlist" <<'EOF' +# Canonical allowlist of paths that gstack-brain-sync will publish. +# One glob per line. Anything not matching stays local. +# Do not edit directly; managed by gstack-artifacts-init. User additions go +# below the marker and survive re-init. +projects/*/learnings.jsonl +projects/*/*-reviews.jsonl +projects/*/ceo-plans/*.md +projects/*/ceo-plans/*/*.md +projects/*/designs/*.md +projects/*/designs/*/*.md +projects/*/timeline.jsonl +retros/*.md +developer-profile.json +builder-journey.md +builder-profile.jsonl +# NOT synced (machine-local UX state): +# projects/*/question-preferences.json (per-machine UX preferences) +# projects/*/question-log.jsonl (audit/derivation log stays with preferences) +# projects/*/question-events.jsonl (same) +# ---- USER ADDITIONS BELOW ---- (survives re-init; above is managed) +EOF + +cat > "$GSTACK_HOME/.brain-privacy-map.json" <<'EOF' +[ + {"pattern": "projects/*/learnings.jsonl", "class": "artifact"}, + {"pattern": "projects/*/*-reviews.jsonl", "class": "artifact"}, + {"pattern": "projects/*/ceo-plans/*.md", "class": "artifact"}, + {"pattern": "projects/*/ceo-plans/*/*.md", "class": "artifact"}, + {"pattern": "projects/*/designs/*.md", "class": "artifact"}, + {"pattern": "projects/*/designs/*/*.md", "class": "artifact"}, + {"pattern": "retros/*.md", "class": "artifact"}, + {"pattern": "builder-journey.md", "class": "artifact"}, + {"pattern": "projects/*/timeline.jsonl", "class": "behavioral"}, + {"pattern": "developer-profile.json", "class": "behavioral"}, + {"pattern": "builder-profile.jsonl", "class": "behavioral"} +] +EOF + +cat > "$GSTACK_HOME/.gitattributes" <<'EOF' +# gstack-artifacts: merge drivers for cross-machine sync conflicts. +*.jsonl merge=jsonl-append +retros/*.md merge=union +projects/*/designs/**/*.md merge=union +projects/*/ceo-plans/**/*.md merge=union +EOF + +# ---- register merge drivers in local git config ---- +git -C "$GSTACK_HOME" config merge.jsonl-append.driver "$SCRIPT_DIR/gstack-jsonl-merge %O %A %B" +git -C "$GSTACK_HOME" config merge.jsonl-append.name "gstack JSONL append-only merger" +git -C "$GSTACK_HOME" config merge.union.driver "cat %A %B > %A.merged && mv %A.merged %A" +git -C "$GSTACK_HOME" config merge.union.name "union concat" + +# ---- install pre-commit hook (defense-in-depth) ---- +HOOK="$GSTACK_HOME/.git/hooks/pre-commit" +mkdir -p "$(dirname "$HOOK")" +cat > "$HOOK" <<'HOOK_EOF' +#!/usr/bin/env bash +# gstack-artifacts pre-commit hook — secret-scan defense-in-depth. +# The primary scanner runs inside gstack-brain-sync BEFORE staging. This hook +# catches any manual `git commit` a user might accidentally run against the +# artifacts repo. +set -uo pipefail + +python3 -c " +import sys, re, subprocess +try: + out = subprocess.check_output(['git', 'diff', '--cached'], stderr=subprocess.DEVNULL).decode('utf-8', 'replace') +except Exception: + sys.exit(0) + +patterns = [ + ('aws-access-key', re.compile(r'AKIA[0-9A-Z]{16}')), + ('github-token', re.compile(r'\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})')), + ('openai-key', re.compile(r'\bsk-[A-Za-z0-9_-]{20,}')), + ('pem-block', re.compile(r'-----BEGIN [A-Z ]{3,}-----')), + ('jwt', re.compile(r'\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b')), + ('bearer-token-json', + re.compile(r'\"(authorization|api[_-]?key|apikey|token|secret|password)\"\s*:\s*\"[A-Za-z0-9_./+=-]{16,}\"', + re.IGNORECASE)), +] +for name, rx in patterns: + if rx.search(out): + sys.stderr.write(f'gstack-artifacts pre-commit: refusing commit — {name} detected in staged diff.\n') + sys.stderr.write('Either edit the offending file, or if intentional, run:\n') + sys.stderr.write(' gstack-brain-sync --skip-file (to permanently exclude)\n') + sys.exit(1) +sys.exit(0) +" +HOOK_EOF +chmod +x "$HOOK" + +# ---- initial commit (idempotent) ---- +cd "$GSTACK_HOME" +git add -f .gitignore .brain-allowlist .brain-privacy-map.json .gitattributes +if git rev-parse HEAD >/dev/null 2>&1; then + if ! git diff --cached --quiet 2>/dev/null; then + git -c user.email="gstack@localhost" -c user.name="gstack-artifacts-init" \ + commit -q -m "chore: gstack-artifacts-init (refresh sync config)" + fi +else + git -c user.email="gstack@localhost" -c user.name="gstack-artifacts-init" \ + commit -q -m "chore: gstack-artifacts-init" +fi + +# ---- initial push ---- +if ! git push -q -u origin main 2>/dev/null; then + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + if git fetch origin 2>/dev/null && git pull --ff-only origin "$CURRENT_BRANCH" 2>/dev/null; then + git push -q -u origin "$CURRENT_BRANCH" || { + echo "Push to $PUSH_URL failed. The remote may have divergent content." >&2 + echo "Try: cd ~/.gstack && git pull --rebase origin $CURRENT_BRANCH && git push origin $CURRENT_BRANCH" >&2 + exit 1 + } + else + echo "Push to $PUSH_URL failed and fetch/merge didn't help." >&2 + echo "Manual recovery: cd ~/.gstack && git status, then push once conflicts are resolved." >&2 + exit 1 + fi +fi + +# ---- write the remote-url helper file (HTTPS canonical) ---- +echo "$CANONICAL_HTTPS" > "$REMOTE_FILE" +chmod 600 "$REMOTE_FILE" + +# ---- print brain-admin hookup command (always print, never auto-execute; +# codex Finding #3) ---- +SOURCE_ID="gstack-artifacts-${USER:-$(whoami)}" +cat < # https → git@host:owner/repo.git +# gstack-artifacts-url --to https # idempotent canonicalization +# gstack-artifacts-url --host # extract hostname +# gstack-artifacts-url --owner-repo # extract owner/repo +# +# Inputs accepted: +# https://github.com/garrytan/gstack-artifacts-garrytan +# https://github.com/garrytan/gstack-artifacts-garrytan.git +# git@github.com:garrytan/gstack-artifacts-garrytan.git +# ssh://git@gitlab.com/garrytan/gstack-artifacts-garrytan.git +# git@gitlab.example.org:team/gstack-artifacts-team.git +# +# Output: the requested form on stdout. Exits non-zero on parse failure with +# an error on stderr. +set -euo pipefail + +usage() { + echo "Usage: gstack-artifacts-url --to {ssh|https} " >&2 + echo " gstack-artifacts-url --host " >&2 + echo " gstack-artifacts-url --owner-repo " >&2 + exit 2 +} + +[ $# -ge 2 ] || usage + +mode="" +to="" +case "$1" in + --to) mode="to"; to="$2"; shift 2 ;; + --host) mode="host"; shift ;; + --owner-repo) mode="owner-repo"; shift ;; + *) usage ;; +esac + +[ $# -eq 1 ] || usage +url="$1" + +# Strip trailing .git for normalization; reattach where needed. +strip_git() { + echo "${1%.git}" +} + +# Parse to (host, owner_repo) regardless of input shape. +parse_url() { + local u="$1" + local host="" owner_repo="" + case "$u" in + https://*) + # https://host/owner/repo[.git] + local rest="${u#https://}" + host="${rest%%/*}" + owner_repo="${rest#*/}" + owner_repo=$(strip_git "$owner_repo") + ;; + ssh://*) + # ssh://git@host/owner/repo[.git] OR ssh://host/owner/repo[.git] + local rest="${u#ssh://}" + # Strip optional user@ + rest="${rest#*@}" + host="${rest%%/*}" + owner_repo="${rest#*/}" + owner_repo=$(strip_git "$owner_repo") + ;; + git@*:*) + # git@host:owner/repo[.git] + local rest="${u#git@}" + host="${rest%%:*}" + owner_repo="${rest#*:}" + owner_repo=$(strip_git "$owner_repo") + ;; + *) + echo "gstack-artifacts-url: unrecognized URL form: $u" >&2 + exit 3 + ;; + esac + if [ -z "$host" ] || [ -z "$owner_repo" ] || [ "$owner_repo" = "$u" ]; then + echo "gstack-artifacts-url: failed to parse host/owner from: $u" >&2 + exit 3 + fi + printf '%s\n%s\n' "$host" "$owner_repo" +} + +parsed=$(parse_url "$url") +host=$(echo "$parsed" | head -1) +owner_repo=$(echo "$parsed" | tail -1) + +case "$mode" in + to) + case "$to" in + ssh) printf 'git@%s:%s.git\n' "$host" "$owner_repo" ;; + https) printf 'https://%s/%s\n' "$host" "$owner_repo" ;; + *) usage ;; + esac + ;; + host) printf '%s\n' "$host" ;; + owner-repo) printf '%s\n' "$owner_repo" ;; +esac diff --git a/test/gstack-artifacts-init.test.ts b/test/gstack-artifacts-init.test.ts new file mode 100644 index 00000000..2ce1810f --- /dev/null +++ b/test/gstack-artifacts-init.test.ts @@ -0,0 +1,320 @@ +/** + * gstack-artifacts-init — provider-selection + brain-admin-hookup tests. + * + * Mirrors the gstack-brain-init-gh-mock.test.ts pattern: install fake gh / + * glab / git binaries on PATH, drive the script's three host-pref branches, + * assert it (a) creates the right repo name, (b) stores HTTPS canonical in + * ~/.gstack-artifacts-remote.txt, (c) prints the "Send this to your brain + * admin" block in the right form depending on --url-form-supported. + * + * Per codex Finding #3: the script always prints the hookup command, never + * auto-executes (no MCP probe). Per Finding #10: stored URL is HTTPS. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const INIT_BIN = path.join(ROOT, 'bin', 'gstack-artifacts-init'); + +let tmpHome: string; +let bareRemote: string; +let fakeBinDir: string; +let ghCallLog: string; +let glabCallLog: string; + +function makeFakeGh(opts: { authStatus?: 'ok' | 'fail'; repoCreate?: 'success' | 'already-exists' | 'fail'; webUrl?: string } = {}) { + const authStatus = opts.authStatus ?? 'ok'; + const repoCreate = opts.repoCreate ?? 'success'; + const webUrl = opts.webUrl ?? `https://github.com/testuser/gstack-artifacts-testuser`; + const script = `#!/bin/bash +echo "gh $@" >> "${ghCallLog}" +case "$1" in + auth) ${authStatus === 'ok' ? 'exit 0' : 'exit 1'} ;; + repo) + shift + case "$1" in + create) + ${ + repoCreate === 'success' + ? 'exit 0' + : repoCreate === 'already-exists' + ? 'echo "GraphQL: Name already exists on this account" >&2; exit 1' + : 'echo "network error" >&2; exit 1' + } + ;; + view) + # gh repo view --json url -q .url + echo "${webUrl}" + exit 0 + ;; + esac + ;; +esac +exit 0 +`; + fs.writeFileSync(path.join(fakeBinDir, 'gh'), script, { mode: 0o755 }); +} + +function makeFakeGlab(opts: { authStatus?: 'ok' | 'fail'; repoCreate?: 'success' | 'fail'; webUrl?: string } = {}) { + const authStatus = opts.authStatus ?? 'ok'; + const repoCreate = opts.repoCreate ?? 'success'; + const webUrl = opts.webUrl ?? 'https://gitlab.com/testuser/gstack-artifacts-testuser'; + const script = `#!/bin/bash +echo "glab $@" >> "${glabCallLog}" +case "$1" in + auth) ${authStatus === 'ok' ? 'exit 0' : 'exit 1'} ;; + repo) + shift + case "$1" in + create) ${repoCreate === 'success' ? 'exit 0' : 'exit 1'} ;; + view) + # glab repo view -F json + echo '{"web_url":"${webUrl}"}' + exit 0 + ;; + esac + ;; +esac +exit 0 +`; + fs.writeFileSync(path.join(fakeBinDir, 'glab'), script, { mode: 0o755 }); +} + +/** + * git shim that no-ops the network calls (ls-remote, fetch, push, pull) so + * tests don't actually need a reachable remote. Real git is used for local + * operations like init / config / commit / remote set-url. This keeps the + * test focused on artifacts-init's branching logic, not git plumbing. + */ +function makeFakeGit() { + const realGit = spawnSync('which', ['git'], { encoding: 'utf-8' }).stdout.trim(); + const script = `#!/bin/bash +# Walk argv past leading -C and similar flags to find the real subcommand. +args=("$@") +i=0 +while [ $i -lt \${#args[@]} ]; do + case "\${args[$i]}" in + -C) i=$((i+2)) ;; + -c) i=$((i+2)) ;; + --) break ;; + -*) i=$((i+1)) ;; + *) break ;; + esac +done +sub="\${args[$i]:-}" +case "$sub" in + ls-remote|fetch|push|pull) exit 0 ;; + *) exec "${realGit}" "$@" ;; +esac +`; + fs.writeFileSync(path.join(fakeBinDir, 'git'), script, { mode: 0o755 }); +} + +function run(argv: string[], opts: { env?: Record; input?: string } = {}) { + // Include the bin/ dir so artifacts-init can find artifacts-url. + const binDir = path.join(ROOT, 'bin'); + const env = { + PATH: `${fakeBinDir}:${binDir}:/usr/bin:/bin:/opt/homebrew/bin`, + GSTACK_HOME: tmpHome, + USER: 'testuser', + HOME: tmpHome, + ...(opts.env || {}), + }; + const res = spawnSync(INIT_BIN, argv, { + env, + encoding: 'utf-8', + input: opts.input, + cwd: ROOT, + }); + return { + stdout: res.stdout || '', + stderr: res.stderr || '', + status: res.status ?? -1, + }; +} + +function readCalls(file: string): string[] { + if (!fs.existsSync(file)) return []; + return fs.readFileSync(file, 'utf-8').trim().split('\n').filter(Boolean); +} + +beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'artifacts-init-')); + bareRemote = fs.mkdtempSync(path.join(os.tmpdir(), 'artifacts-bare-')); + fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'artifacts-fake-bin-')); + ghCallLog = path.join(fakeBinDir, 'gh-calls.log'); + glabCallLog = path.join(fakeBinDir, 'glab-calls.log'); + spawnSync('git', ['init', '--bare', '-q', '-b', 'main', bareRemote]); + makeFakeGit(); +}); + +afterEach(() => { + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(bareRemote, { recursive: true, force: true }); + fs.rmSync(fakeBinDir, { recursive: true, force: true }); +}); + +describe('gstack-artifacts-init provider selection', () => { + test('--host github invokes gh repo create with gstack-artifacts-$USER', () => { + makeFakeGh({}); + const r = run(['--host', 'github']); + if (r.status !== 0) console.error('STDERR:', r.stderr); + expect(r.status).toBe(0); + const calls = readCalls(ghCallLog); + const createCall = calls.find((c) => c.startsWith('gh repo create')); + expect(createCall).toBeDefined(); + expect(createCall).toContain('gstack-artifacts-testuser'); + expect(createCall).toContain('--private'); + }); + + test('--host gitlab invokes glab repo create', () => { + makeFakeGlab({}); + const r = run(['--host', 'gitlab']); + if (r.status !== 0) console.error('STDERR:', r.stderr); + expect(r.status).toBe(0); + const calls = readCalls(glabCallLog); + const createCall = calls.find((c) => c.startsWith('glab repo create')); + expect(createCall).toBeDefined(); + expect(createCall).toContain('gstack-artifacts-testuser'); + expect(createCall).toContain('--private'); + }); + + test('both gh and glab authed → interactive prompt picks GitHub by default (Enter = 1)', () => { + makeFakeGh({}); + makeFakeGlab({}); + const r = run([], { input: '\n' }); + expect(r.status).toBe(0); + expect(readCalls(ghCallLog).some((c) => c.startsWith('gh repo create'))).toBe(true); + expect(readCalls(glabCallLog).some((c) => c.startsWith('glab repo create'))).toBe(false); + }); + + test('both gh and glab authed → user picks 2 → glab is used', () => { + makeFakeGh({}); + makeFakeGlab({}); + const r = run([], { input: '2\n' }); + expect(r.status).toBe(0); + expect(readCalls(glabCallLog).some((c) => c.startsWith('glab repo create'))).toBe(true); + expect(readCalls(ghCallLog).some((c) => c.startsWith('gh repo create'))).toBe(false); + }); + + test('only gh authed → defaults to github (no prompt)', () => { + makeFakeGh({}); + // No glab installed. + const r = run([]); + expect(r.status).toBe(0); + expect(readCalls(ghCallLog).some((c) => c.startsWith('gh repo create'))).toBe(true); + }); + + test('only glab authed → defaults to gitlab (no prompt)', () => { + makeFakeGlab({}); + const r = run([]); + expect(r.status).toBe(0); + expect(readCalls(glabCallLog).some((c) => c.startsWith('glab repo create'))).toBe(true); + }); + + test('neither authed → falls through to manual URL paste', () => { + // No gh, no glab fakes. + const r = run([], { input: 'https://github.com/testuser/gstack-artifacts-testuser\n' }); + expect(r.status).toBe(0); + expect(r.stderr).toContain('Neither gh nor glab'); + }); +}); + +describe('gstack-artifacts-init canonical URL storage (codex Finding #10)', () => { + test('stores HTTPS URL canonical in ~/.gstack-artifacts-remote.txt', () => { + makeFakeGh({ webUrl: 'https://github.com/testuser/gstack-artifacts-testuser' }); + const r = run(['--host', 'github']); + expect(r.status).toBe(0); + const remoteFile = path.join(tmpHome, '.gstack-artifacts-remote.txt'); + expect(fs.existsSync(remoteFile)).toBe(true); + const stored = fs.readFileSync(remoteFile, 'utf-8').trim(); + // HTTPS, NOT SSH (codex Finding #10: canonical = HTTPS). + expect(stored).toMatch(/^https:\/\//); + expect(stored).toBe('https://github.com/testuser/gstack-artifacts-testuser'); + }); + + test('strips trailing .git from gh repo view output', () => { + makeFakeGh({ webUrl: 'https://github.com/testuser/gstack-artifacts-testuser.git' }); + const r = run(['--host', 'github']); + expect(r.status).toBe(0); + const stored = fs.readFileSync(path.join(tmpHome, '.gstack-artifacts-remote.txt'), 'utf-8').trim(); + expect(stored).toBe('https://github.com/testuser/gstack-artifacts-testuser'); + }); + + test('configures git origin with SSH form (derived from canonical HTTPS)', () => { + makeFakeGh({ webUrl: 'https://github.com/testuser/gstack-artifacts-testuser' }); + const r = run(['--host', 'github']); + expect(r.status).toBe(0); + const remote = spawnSync('git', ['-C', tmpHome, 'remote', 'get-url', 'origin'], { encoding: 'utf-8' }); + expect(remote.stdout.trim()).toBe('git@github.com:testuser/gstack-artifacts-testuser.git'); + }); +}); + +describe('gstack-artifacts-init brain-admin hookup printout (codex Finding #3)', () => { + test('--url-form-supported false prints the two-line clone-then-path form', () => { + makeFakeGh({}); + const r = run(['--host', 'github', '--url-form-supported', 'false']); + expect(r.status).toBe(0); + expect(r.stdout).toContain('Send this to your brain admin'); + expect(r.stdout).toContain('git clone'); + expect(r.stdout).toContain('--path'); + expect(r.stdout).toContain('--federated'); + // The forward-compat hint should still appear. + expect(r.stdout).toContain('When gbrain ships --url support'); + }); + + test('--url-form-supported true prints the one-liner with --url', () => { + makeFakeGh({}); + const r = run(['--host', 'github', '--url-form-supported', 'true']); + expect(r.status).toBe(0); + expect(r.stdout).toContain('Send this to your brain admin'); + expect(r.stdout).toContain('gbrain sources add gstack-artifacts-testuser --url'); + expect(r.stdout).not.toContain('git clone'); + }); + + test('the gbrain command line uses canonical HTTPS, not SSH', () => { + makeFakeGh({ webUrl: 'https://github.com/testuser/gstack-artifacts-testuser' }); + const r = run(['--host', 'github', '--url-form-supported', 'true']); + expect(r.status).toBe(0); + // Find the line with the gbrain command and check ITS URL is HTTPS. + const gbrainLine = r.stdout + .split('\n') + .find((l) => l.includes('gbrain sources add')); + expect(gbrainLine).toBeDefined(); + expect(gbrainLine).toContain('https://github.com/testuser/gstack-artifacts-testuser'); + expect(gbrainLine).not.toContain('git@github.com'); + // Note: the SSH form does appear in the printout as informational + // (the "Push: ..." line), which is intentional — that's the URL git + // actually uses for push. + }); +}); + +describe('gstack-artifacts-init idempotency', () => { + test('--remote bypasses provider selection entirely', () => { + makeFakeGh({}); + const r = run(['--remote', 'https://github.com/testuser/gstack-artifacts-testuser']); + expect(r.status).toBe(0); + // gh auth was checked (still useful for provider detection) but no repo create. + expect(readCalls(ghCallLog).some((c) => c.startsWith('gh repo create'))).toBe(false); + }); + + test('re-run with same --remote is safe (no conflict error)', () => { + makeFakeGh({}); + const url = 'https://github.com/testuser/gstack-artifacts-testuser'; + run(['--remote', url]); + const r2 = run(['--remote', url]); + expect(r2.status).toBe(0); + }); + + test('re-run with DIFFERENT --remote exits 1 with conflict message', () => { + makeFakeGh({}); + run(['--remote', 'https://github.com/testuser/gstack-artifacts-testuser']); + const r2 = run(['--remote', 'https://github.com/other/repo']); + expect(r2.status).not.toBe(0); + expect(r2.stderr).toContain('already a git repo'); + }); +}); diff --git a/test/gstack-artifacts-url.test.ts b/test/gstack-artifacts-url.test.ts new file mode 100644 index 00000000..efecbfb2 --- /dev/null +++ b/test/gstack-artifacts-url.test.ts @@ -0,0 +1,87 @@ +/** + * gstack-artifacts-url — URL canonicalization helper. + * + * Centralizes HTTPS↔SSH conversion so callers don't each string-mangle. Per + * codex Finding #10: store one canonical form (HTTPS) and derive all others. + */ + +import { describe, test, expect } from 'bun:test'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const URL_BIN = path.join(ROOT, 'bin', 'gstack-artifacts-url'); + +function run(args: string[]): { code: number; stdout: string; stderr: string } { + const r = spawnSync(URL_BIN, args, { encoding: 'utf-8' }); + return { + code: r.status ?? -1, + stdout: (r.stdout || '').trim(), + stderr: (r.stderr || '').trim(), + }; +} + +describe('gstack-artifacts-url', () => { + test('--to ssh from canonical https', () => { + const r = run(['--to', 'ssh', 'https://github.com/garrytan/gstack-artifacts-garrytan']); + expect(r.code).toBe(0); + expect(r.stdout).toBe('git@github.com:garrytan/gstack-artifacts-garrytan.git'); + }); + + test('--to ssh from https-with-.git', () => { + const r = run(['--to', 'ssh', 'https://github.com/garrytan/gstack-artifacts-garrytan.git']); + expect(r.stdout).toBe('git@github.com:garrytan/gstack-artifacts-garrytan.git'); + }); + + test('--to https is idempotent on https input', () => { + const r = run(['--to', 'https', 'https://github.com/garrytan/gstack-artifacts-garrytan']); + expect(r.stdout).toBe('https://github.com/garrytan/gstack-artifacts-garrytan'); + }); + + test('--to https from git@host:owner/repo.git', () => { + const r = run(['--to', 'https', 'git@github.com:garrytan/gstack-artifacts-garrytan.git']); + expect(r.stdout).toBe('https://github.com/garrytan/gstack-artifacts-garrytan'); + }); + + test('--to https from ssh:// scheme (gitlab self-hosted style)', () => { + const r = run(['--to', 'https', 'ssh://git@gitlab.example.org/team/gstack-artifacts-team.git']); + expect(r.stdout).toBe('https://gitlab.example.org/team/gstack-artifacts-team'); + }); + + test('--host extracts hostname from any form', () => { + expect(run(['--host', 'https://github.com/x/y']).stdout).toBe('github.com'); + expect(run(['--host', 'git@gitlab.com:x/y.git']).stdout).toBe('gitlab.com'); + expect(run(['--host', 'ssh://git@gitlab.example.org/x/y.git']).stdout).toBe('gitlab.example.org'); + }); + + test('--owner-repo extracts the path segment', () => { + expect(run(['--owner-repo', 'https://github.com/garrytan/gstack-artifacts-garrytan']).stdout) + .toBe('garrytan/gstack-artifacts-garrytan'); + expect(run(['--owner-repo', 'git@github.com:team/gstack-artifacts-team.git']).stdout) + .toBe('team/gstack-artifacts-team'); + }); + + test('rejects unrecognized URL form with exit 3', () => { + const r = run(['--to', 'ssh', 'not a url']); + expect(r.code).toBe(3); + expect(r.stderr).toContain('unrecognized URL form'); + }); + + test('rejects missing args with exit 2', () => { + expect(run([]).code).toBe(2); + expect(run(['--to']).code).toBe(2); + expect(run(['--to', 'ssh']).code).toBe(2); + }); + + test('rejects unknown --to target', () => { + const r = run(['--to', 'svn', 'https://github.com/x/y']); + expect(r.code).toBe(2); + }); + + test('round-trip: https → ssh → https is identity', () => { + const original = 'https://github.com/garrytan/gstack-artifacts-garrytan'; + const ssh = run(['--to', 'ssh', original]).stdout; + const back = run(['--to', 'https', ssh]).stdout; + expect(back).toBe(original); + }); +});