Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,18 @@ deploy job is added there, this decision needs revisiting.
- Steps 8's `gh` incantations disappear from the walkthrough, and the trap
they were warning about cannot be walked into.
- The command is tested against a stubbed `gh` (`tests/publish.bats`), the
same technique `tests/install.bats` uses for `curl` and `docker`. What that
does not cover is whether GitHub accepts the ruleset payload; the shape is
asserted to be valid JSON carrying the three rules, and the live path was
exercised only far enough to observe the plan limit above.
same technique `tests/install.bats` uses for `curl` and `docker`. The
ruleset payload was additionally verified live, against a public repository:
GitHub accepts it and reports the ruleset `main` as `active` with
`deletion`, `non_fast_forward` and `pull_request`.
- `gh repo create` does three things in one call — create, add the remote,
push — and a failure in the second or third leaves the first behind.
Observed while verifying the above. Every step here is idempotent, so a
second run finishes the job; the failure message says the repository may
already exist rather than implying nothing happened.
- Creating a repository while `origin` already points elsewhere is refused.
`gh` fails on it regardless, with "Unable to add remote", which says nothing
about the project naming one repository and pushing to another.
- A project whose GitHub repository is renamed after generation still breaks
every reference to it. This command does not fix that; it removes one way
of arriving there.
Expand Down
16 changes: 12 additions & 4 deletions docs/tour/09-wizard.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,15 @@ appears in all three; a bare call that opened a menu there would hang them
as a timeout, not fail as an error, so the TTY check is the whole safety
argument.

The first question is shape — `web+api`, `app`, `api`, or `web` — not
frontend and backend separately. `laravel-inertia` sets
The first question is what to do: `new`, and — only when the wizard was run
from inside a project scaffold generated — `update` and `publish`. Both of
those act on a project that already exists, so outside one they are not
offered at all, for the same reason a `web` project is never asked about a
database. With only one action to choose from the screen is skipped, because a
menu with one answer asks nothing.

For `new`, the next question is shape — `web+api`, `app`, `api`, or `web` —
not frontend and backend separately. `laravel-inertia` sets
`ADAPTER_ROLE=app`: it is one application serving both tiers, so asking
"frontend?" then "backend?" has no honest answer for it on a two-question
flow. Asking shape first means that combination is never assembled to
Expand Down Expand Up @@ -41,8 +48,9 @@ The second project from the same answers is scripted rather than clicked.

## Read this

- `lib/wizard.sh` — `wizard_options`, `wizard_questions`, `wizard_prompt_for`,
`wizard_new_args`, `wizard_command`: pure, string-in/string-out functions
- `lib/wizard.sh` — `wizard_actions`, `wizard_options`, `wizard_questions`,
`wizard_prompt_for`, `wizard_new_args`, `wizard_command`: pure,
string-in/string-out functions
that hold everything that could be wrong about the wizard's logic, tested
without a terminal at all.
- `lib/tui.sh` — the terminal machinery: hiding and restoring the cursor,
Expand Down
8 changes: 8 additions & 0 deletions lib/project.sh
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,14 @@ record_scaffold_app() {
printf '"%s" = "%s"\n' "$rel" "$adapter" >> "$file"
}

# is_scaffold_project <dir>
# The marker init_project writes and nothing else has a reason to. mise.toml
# alone is not proof — any repository can carry one. Was written out three
# times in `scaffold` before the wizard needed a fourth.
is_scaffold_project() {
[ -f "${1}/mise.toml" ] && grep -q '^monorepo_root = true$' "${1}/mise.toml"
}

# init_project <dir> <name>
init_project() {
local dir="$1" name="$2"
Expand Down
8 changes: 7 additions & 1 deletion lib/publish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,15 @@ gh_repo_exists() {
create_repo() {
local project="$1" slug="$2" visibility="$3"

# One `gh` call doing three things — create, add the remote, push — and a
# failure in the second or third leaves the first behind. Observed: adding
# the remote failed and the repository existed anyway, so the next run
# reported "already exists" about a repository this command had just made.
# That path is now idempotent rather than surprising, but the message has to
# say what may be out there.
gh repo create "$slug" "--${visibility}" --source "$project" \
--remote origin --push >/dev/null \
|| die "could not create ${slug}"
|| die "could not finish creating ${slug} — it may exist on GitHub already, with no remote or no branch pushed. Check it, then run this again: everything here is idempotent."
}

# allow_actions_to_open_pull_requests <slug>
Expand Down
27 changes: 27 additions & 0 deletions lib/wizard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,31 @@ wizard_order_options() {
# wizard_questions accepts only what it lists — the single place either side
# reads, so a shape added to one and not the other fails a test instead of
# reaching a user four screens in.
# wizard_actions <inside-a-project:0|1>
# What this wizard can do from where it was run. `update` and `publish` both
# act on a project that already exists, so outside one they are not offered at
# all — the same reason a `web` project is never asked about a database
# (docs/tour/09-wizard.md): a refusal the user can walk into is worse than one
# they cannot.
wizard_actions() {
printf 'new\tgenerate a project\n'
[ "${1:-0}" = 1 ] || return 0
printf 'update\tbring this project up to this toolbox\n'
printf 'publish\tcreate its GitHub repository and apply its settings\n'
}

# The first question, asked before anything else is known.
WIZARD_ACTION_PROMPT='What do you want to do?'

# wizard_visibilities — publish's one real decision. Private first, because a
# client's project is the case this toolbox exists for.
wizard_visibilities() {
printf 'private\tonly people you add can see it\n'
printf 'public\tanyone can see it\n'
}

WIZARD_VISIBILITY_PROMPT='Repository visibility'

wizard_shapes() {
printf 'web+api\tseparate frontend and backend, one repository\n'
printf 'app\tone application serving both pages and data\n'
Expand Down Expand Up @@ -112,6 +137,8 @@ WIZARD_SHAPE_PROMPT='What are you building?'
# than overflowing a hand-counted constant.
wizard_prompt_width() {
local kind text width=${#WIZARD_SHAPE_PROMPT}
(( ${#WIZARD_ACTION_PROMPT} > width )) && width=${#WIZARD_ACTION_PROMPT}
(( ${#WIZARD_VISIBILITY_PROMPT} > width )) && width=${#WIZARD_VISIBILITY_PROMPT}
for kind in web api app database cache; do
text="$(wizard_prompt_for "$kind")"
(( ${#text} > width )) && width=${#text}
Expand Down
95 changes: 81 additions & 14 deletions scaffold
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ cmd_add() {
# writes and nothing else has a reason to, but that detail means nothing
# to a user — the message stays in their terms and says what to do next,
# like its sibling above.
{ [ -f "${project}/mise.toml" ] && grep -q '^monorepo_root = true$' "${project}/mise.toml"; } \
is_scaffold_project "$project" \
|| die "not a scaffold project (${project}): run 'scaffold add' from inside a project created by 'scaffold new'"

# Same reason as cmd_new's: every mise subprocess this run makes would
Expand Down Expand Up @@ -594,7 +594,7 @@ cmd_update() {
|| die "not inside a git repository: run 'scaffold update' from inside a project created by 'scaffold new', or give its path"
fi

{ [ -f "${project}/mise.toml" ] && grep -q '^monorepo_root = true$' "${project}/mise.toml"; } \
is_scaffold_project "$project" \
|| die "not a scaffold project (${project})"

local from
Expand Down Expand Up @@ -699,7 +699,7 @@ cmd_publish() {
|| die "not inside a git repository: run 'scaffold publish' from inside a project created by 'scaffold new', or give its path"
fi

{ [ -f "${project}/mise.toml" ] && grep -q '^monorepo_root = true$' "${project}/mise.toml"; } \
is_scaffold_project "$project" \
|| die "not a scaffold project (${project})"

local slug; slug="$(repo_slug "$project")"
Expand All @@ -715,6 +715,21 @@ cmd_publish() {
|| die "publish creates ${slug} from the branch you are on, and that branch becomes its default — check out main first (you are on ${branch})"
[ -z "$(git -C "$project" status --porcelain)" ] \
|| die "${project} has uncommitted changes — commit them first, so what reaches ${slug} is what you reviewed"

# An origin pointing somewhere else, while ${slug} does not exist, means
# this project is already pushing to a repository that is not the one it
# names. Creating a second one beside it is the confusion this command
# exists to prevent, and `gh repo create --remote origin` fails on it
# anyway — with "Unable to add remote", which says nothing about why.
local origin=""
if origin="$(git -C "$project" remote get-url origin 2>/dev/null)"; then
die "$(printf '%s\n' \
"${project} already pushes to ${origin}, but it names ${slug}." \
"Publishing would create a second repository beside the one it is already using." \
"Point the project at the repository it has ([vars] image in mise.toml, and" \
"the same pair in compose.yaml, install.sh and the build workflows), or drop" \
"the remote with 'git -C ${project} remote remove origin'.")"
fi
fi

if [ "$dry_run" -eq 1 ]; then
Expand Down Expand Up @@ -782,6 +797,42 @@ _wizard_select_or_cancel() {
tui_select "$@" || exit 130
}

# _wizard_confirm <prompt> — the same y/N gate cmd_wizard ends on. `y`, not
# Enter: everything below this line changes something that already exists.
_wizard_confirm() {
printf '%b' "${BOLD}${1} [y/N] ${RESET}" >&2
local reply=""
IFS= read -r reply || true
case "$reply" in y|Y) return 0 ;; *) return 1 ;; esac
}

# _wizard_update <project>
_wizard_update() {
tui_end
log ""
wizard_echo_command "scaffold update"
log ""
_wizard_confirm "Bring ${1} up to this toolbox?" || return 0
[ "${SCAFFOLD_WIZARD_DRY_RUN:-0}" = 1 ] && return 0
cmd_update "$1"
}

# _wizard_publish <project>
_wizard_publish() {
local -a options
mapfile -t options < <(wizard_visibilities)
_wizard_select_or_cancel "$WIZARD_VISIBILITY_PROMPT" "${options[@]}"
local visibility="$TUI_CHOICE"

tui_end
log ""
wizard_echo_command "scaffold publish --${visibility}"
log ""
_wizard_confirm "Publish ${1} to GitHub?" || return 0
[ "${SCAFFOLD_WIZARD_DRY_RUN:-0}" = 1 ] && return 0
cmd_publish "$1" "--${visibility}"
}

# cmd_wizard — name, shape, then one to four more screens depending on the
# shape, from wizard_questions(shape). Ends on a summary and a confirm;
# SCAFFOLD_WIZARD_DRY_RUN=1 stops it just before cmd_new would run, which is
Expand All @@ -790,13 +841,35 @@ _wizard_select_or_cancel() {
cmd_wizard() {
tui_begin

local name; name="$(tui_prompt_name)"
local listing; listing="$(cmd_list)"

# Read by tui_select to pad every question to one answer column.
# Read by tui_select to pad every question to one answer column. Set before
# the first screen, not after the name prompt, now that a screen comes first.
# shellcheck disable=SC2034 # read by lib/tui.sh, not by this file
TUI_ANSWER_COLUMN="$(wizard_prompt_width)"

# update and publish act on a project that already exists, so they are
# offered only from inside one.
local here=""
here="$(git rev-parse --show-toplevel 2>/dev/null)" || here=""
[ -n "$here" ] && is_scaffold_project "$here" || here=""

local action=new
local -a action_options
mapfile -t action_options < <(wizard_actions "$([ -n "$here" ] && printf 1 || printf 0)")
# Only when there is a choice: a menu with one answer asks nothing and
# costs a keystroke.
if [ "${#action_options[@]}" -gt 1 ]; then
_wizard_select_or_cancel "$WIZARD_ACTION_PROMPT" "${action_options[@]}"
action="$TUI_CHOICE"
fi

case "$action" in
update) _wizard_update "$here"; return ;;
publish) _wizard_publish "$here"; return ;;
esac

local name; name="$(tui_prompt_name)"
local listing; listing="$(cmd_list)"

local -a shape_options
mapfile -t shape_options < <(wizard_shapes)
_wizard_select_or_cancel "$WIZARD_SHAPE_PROMPT" "${shape_options[@]}"
Expand Down Expand Up @@ -827,13 +900,7 @@ cmd_wizard() {

log ""

printf '%b' "${BOLD}Generate this project? [y/N] ${RESET}" >&2
local reply=""
IFS= read -r reply || true
case "$reply" in
y|Y) ;;
*) return 0 ;;
esac
_wizard_confirm "Generate this project?" || return 0

if [ "${SCAFFOLD_WIZARD_DRY_RUN:-0}" = 1 ]; then
return 0
Expand Down
16 changes: 16 additions & 0 deletions tests/publish.bats
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,22 @@ _project() {
[[ "$output" == *"uncommitted changes"* ]]
}

@test "publish refuses to create a second repository beside an existing origin" {
# `gh repo create --remote origin` fails on this anyway, with "Unable to add
# remote" — a message about git that says nothing about the project pointing
# at two different repositories.
_stub_gh
_project
git -C "$PROJECT" remote add origin https://github.com/acme/somewhere-else.git

GH_SCENARIO=absent run scaffold publish "$PROJECT"
[ "$status" -ne 0 ]
[[ "$output" == *"already pushes to"* ]]
[[ "$output" == *"acme/demo"* ]]
run grep -c 'repo create' "$GH_LOG"
[ "$output" = 0 ]
}

@test "a dry run asks nothing of GitHub but whether the repository exists" {
_stub_gh
_project
Expand Down
Loading