From 4d346f41b0f0c5bb884e858740ca369c38d27f28 Mon Sep 17 00:00:00 2001 From: Morpheus Date: Wed, 23 Sep 2026 20:59:52 -0300 Subject: [PATCH 1/2] [Feat] Create CodeIgniter, CakePHP and Slim sites from the Create menu Add New CodeIgniter/CakePHP/Slim site entries to the Sites Create menu, scaffolding each framework's official Composer template via a new composer_project create-site job and a shared GUI wizard. - IPC: additive Framework::{Codeigniter, Cakephp, Slim} variants - Daemon: composer create-project job; external Composer executed directly; fail when no composer.json was produced - CodeIgniter: .env from the env template (development, app.baseURL), kept in sync on HTTPS/domain changes by codeigniter_url_sync - public_url: shared browser-facing URL (port on unredirected rootless fallback), also fixing the Laravel job's hard-coded serving line - Shared resolve_composer/build_job_bin/composed_path in create_site - Wizard keeps polling when closed and resumes on reopen Closes #39, closes #40, closes #41 --- .../CreateComposerSiteWizard.spec.ts | 206 ++++++ .../site-create/CreateComposerSiteWizard.vue | 666 ++++++++++++++++++ apps/yerd-gui/src/ipc/types.ts | 6 +- apps/yerd-gui/src/views/SitesView.vue | 38 +- bin/yerdd/src/codeigniter_url_sync.rs | 131 ++++ bin/yerdd/src/create_site/composer_project.rs | 410 +++++++++++ bin/yerdd/src/create_site/laravel.rs | 150 +--- bin/yerdd/src/create_site/mod.rs | 241 ++++++- bin/yerdd/src/ipc_server.rs | 3 +- bin/yerdd/src/lib.rs | 2 + bin/yerdd/src/public_url.rs | 71 ++ crates/yerd-ipc/src/create.rs | 9 + crates/yerd-ipc/tests/wire_stability.rs | 26 + docs/developer/binaries/yerdd.md | 6 +- docs/guide/desktop-app.md | 2 +- docs/guide/sites.md | 16 + 16 files changed, 1829 insertions(+), 154 deletions(-) create mode 100644 apps/yerd-gui/src/components/site-create/CreateComposerSiteWizard.spec.ts create mode 100644 apps/yerd-gui/src/components/site-create/CreateComposerSiteWizard.vue create mode 100644 bin/yerdd/src/codeigniter_url_sync.rs create mode 100644 bin/yerdd/src/create_site/composer_project.rs create mode 100644 bin/yerdd/src/public_url.rs diff --git a/apps/yerd-gui/src/components/site-create/CreateComposerSiteWizard.spec.ts b/apps/yerd-gui/src/components/site-create/CreateComposerSiteWizard.spec.ts new file mode 100644 index 00000000..7b307422 --- /dev/null +++ b/apps/yerd-gui/src/components/site-create/CreateComposerSiteWizard.spec.ts @@ -0,0 +1,206 @@ +import { flushPromises, mount } from "@vue/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import CreateComposerSiteWizard from "./CreateComposerSiteWizard.vue"; +import type { ComposerFramework, ToolStatus } from "@/ipc/types"; + +const createSite = vi.hoisted(() => vi.fn()); +const jobStatus = vi.hoisted(() => vi.fn()); +const listTools = vi.hoisted(() => vi.fn()); + +vi.mock("@/ipc/client", () => ({ + IpcError: class IpcError extends Error {}, + availablePhp: vi.fn(), + createSite, + installPhpWithProgress: vi.fn(), + installToolStreamed: vi.fn(), + jobCancel: vi.fn(), + jobStatus, + listTools, + openInBrowser: vi.fn(), + openPath: vi.fn(), + pickDirectory: vi.fn(), + pollJobToEnd: vi.fn(), +})); + +vi.mock("@/composables/useDaemon", () => ({ + useDaemon: () => ({ refresh: vi.fn() }), +})); + +vi.mock("@/composables/useToast", () => ({ + useToast: () => ({ success: vi.fn(), error: vi.fn() }), +})); + +function composer(overrides: Partial = {}): ToolStatus { + return { + id: "composer", + display_name: "Composer", + installed: true, + version: "2.8.0", + binaries: ["composer"], + ...overrides, + }; +} + +async function mountWizard(framework: ComposerFramework, phpVersions: string[]) { + const w = mount(CreateComposerSiteWizard, { + props: { + open: true, + framework, + parkedFolders: ["/srv"], + phpVersions, + defaultPhp: "", + tld: "test", + report: null, + }, + global: { stubs: { teleport: true } }, + }); + await flushPromises(); + return w; +} + +function byText(w: Awaited>, text: string) { + return w.findAll("button").find((b) => b.text().includes(text)); +} + +describe("CreateComposerSiteWizard", () => { + beforeEach(() => { + createSite.mockReset(); + jobStatus.mockReset(); + listTools.mockReset(); + listTools.mockResolvedValue([composer()]); + }); + + it.each([ + ["codeigniter", "CodeIgniter"], + ["cakephp", "CakePHP"], + ["slim", "Slim"], + ] as const)("titles the %s wizard after the framework", async (framework, label) => { + const w = await mountWizard(framework, ["8.4"]); + expect(w.text()).toContain(`Create a new ${label} site`); + }); + + it("submits a spec tagged with the chosen framework on the newest supported PHP", async () => { + createSite.mockResolvedValue("job-1"); + jobStatus.mockResolvedValue({ + log: [], + next_cursor: 0, + phase: "Done", + state: "succeeded", + error: null, + }); + const w = await mountWizard("cakephp", ["8.3", "8.4"]); + + await w.find("#ccs-name").setValue("shop"); + await byText(w, "Next")!.trigger("click"); + await byText(w, "Create site")!.trigger("click"); + await flushPromises(); + + expect(createSite).toHaveBeenCalledWith({ + name: "shop", + parent_dir: "/srv", + php: "8.4", + secure: false, + framework: { framework: "cakephp" }, + }); + expect(w.emitted("created")).toBeTruthy(); + }); + + it("shows the lowercased folder the daemon will create", async () => { + const w = await mountWizard("slim", ["8.4"]); + + await w.find("#ccs-name").setValue("Shop"); + await byText(w, "Next")!.trigger("click"); + + expect(w.text()).toContain("/srv/shop"); + expect(w.text()).not.toContain("/srv/Shop"); + }); + + it("re-enables Cancel when a cancelled create is retried", async () => { + createSite.mockResolvedValue("job-1"); + const status = (state: string) => ({ + log: [], + next_cursor: 0, + phase: "Scaffolding", + state, + error: null, + }); + jobStatus + .mockResolvedValueOnce(status("running")) + .mockResolvedValueOnce(status("cancelled")) + .mockResolvedValue(status("running")); + const w = await mountWizard("slim", ["8.4"]); + + await w.find("#ccs-name").setValue("shop"); + await byText(w, "Next")!.trigger("click"); + await byText(w, "Create site")!.trigger("click"); + await flushPromises(); + await byText(w, "Cancel")!.trigger("click"); + expect(byText(w, "Cancelling")).toBeTruthy(); + + await vi.waitFor(() => expect(byText(w, "Back")).toBeTruthy(), { timeout: 2000 }); + await byText(w, "Back")!.trigger("click"); + await byText(w, "Create site")!.trigger("click"); + await flushPromises(); + + const cancel = byText(w, "Cancel")!; + expect(cancel.text()).toBe("Cancel"); + expect(cancel.attributes("disabled")).toBeUndefined(); + w.unmount(); + }); + + it("keeps polling after the dialog closes and resumes progress on reopen", async () => { + createSite.mockResolvedValue("job-1"); + const status = (state: string) => ({ + log: [], + next_cursor: 0, + phase: "Scaffolding", + state, + error: null, + }); + jobStatus + .mockResolvedValueOnce(status("running")) + .mockResolvedValueOnce(status("running")) + .mockResolvedValue(status("succeeded")); + const w = await mountWizard("slim", ["8.4"]); + + await w.find("#ccs-name").setValue("shop"); + await byText(w, "Next")!.trigger("click"); + await byText(w, "Create site")!.trigger("click"); + await flushPromises(); + + await w.setProps({ open: false }); + await w.setProps({ open: true }); + expect(w.find("#ccs-name").exists()).toBe(false); + expect(w.text()).toContain("Scaffolding"); + + await w.setProps({ open: false }); + await vi.waitFor(() => expect(w.emitted("created")).toBeTruthy(), { timeout: 3000 }); + w.unmount(); + }); + + it("gates on PHP when every installed version is below the framework minimum", async () => { + const w = await mountWizard("codeigniter", ["7.4"]); + expect(w.text()).toContain("A few tools are needed first"); + expect(w.text()).toContain("Installed, but not 8.1+"); + }); + + it("accepts older PHP for Slim", async () => { + const w = await mountWizard("slim", ["7.4"]); + expect(w.text()).not.toContain("A few tools are needed first"); + expect(w.find("#ccs-name").exists()).toBe(true); + }); + + it("treats an external Composer as satisfying the prerequisite", async () => { + listTools.mockResolvedValue([composer({ installed: false, external: true })]); + const w = await mountWizard("slim", ["8.4"]); + expect(w.text()).not.toContain("A few tools are needed first"); + }); + + it("gates on Composer when it is neither managed nor external", async () => { + listTools.mockResolvedValue([]); + const w = await mountWizard("slim", ["8.4"]); + expect(w.text()).toContain("A few tools are needed first"); + expect(byText(w, "Install missing tools")).toBeTruthy(); + }); +}); diff --git a/apps/yerd-gui/src/components/site-create/CreateComposerSiteWizard.vue b/apps/yerd-gui/src/components/site-create/CreateComposerSiteWizard.vue new file mode 100644 index 00000000..38cc895d --- /dev/null +++ b/apps/yerd-gui/src/components/site-create/CreateComposerSiteWizard.vue @@ -0,0 +1,666 @@ + + + + + diff --git a/apps/yerd-gui/src/ipc/types.ts b/apps/yerd-gui/src/ipc/types.ts index 78f50c35..4dd3787c 100644 --- a/apps/yerd-gui/src/ipc/types.ts +++ b/apps/yerd-gui/src/ipc/types.ts @@ -462,7 +462,11 @@ export interface WordPressOptions { */ export type Framework = | { framework: "laravel"; options: LaravelOptions } - | { framework: "wordpress"; options: WordPressOptions }; + | { framework: "wordpress"; options: WordPressOptions } + | { framework: ComposerFramework }; + +/** Frameworks scaffolded with `composer create-project`, which take no options. */ +export type ComposerFramework = "codeigniter" | "cakephp" | "slim"; /** crates/yerd-ipc/src/create.rs - CreateSiteSpec. */ export interface CreateSiteSpec { diff --git a/apps/yerd-gui/src/views/SitesView.vue b/apps/yerd-gui/src/views/SitesView.vue index 82833b2e..20ca7618 100644 --- a/apps/yerd-gui/src/views/SitesView.vue +++ b/apps/yerd-gui/src/views/SitesView.vue @@ -3,8 +3,11 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue"; import { ArrowDown, ArrowUp, + CakeSlice, ChevronDown, ChevronRight, + Feather, + Flame, FolderMinus, FolderOpen, FolderPlus, @@ -22,6 +25,7 @@ import { import CreateLaravelWizard from "@/components/site-create/CreateLaravelWizard.vue"; import CreateWordPressWizard from "@/components/site-create/CreateWordPressWizard.vue"; +import CreateComposerSiteWizard from "@/components/site-create/CreateComposerSiteWizard.vue"; import SiteCard from "@/components/SiteCard.vue"; import SiteDetailsSidebar from "@/components/SiteDetailsSidebar.vue"; import PageHeader from "@/components/PageHeader.vue"; @@ -69,7 +73,7 @@ import { unlink, unpark, } from "@/ipc/client"; -import type { GroupsState, Site, SiteEntry } from "@/ipc/types"; +import type { ComposerFramework, GroupsState, Site, SiteEntry } from "@/ipc/types"; const toast = useToast(); const { report } = useDaemon(); @@ -341,6 +345,8 @@ const groupSelectOptions = computed(() => [ // ── create new site ── const createOpen = ref(false); const wordpressCreateOpen = ref(false); +const COMPOSER_FRAMEWORKS: ComposerFramework[] = ["codeigniter", "cakephp", "slim"]; +const composerCreateOpen = ref(null); const phpVersionList = computed(() => (report.value?.php ?? []).map((p) => p.version)); const defaultPhp = computed(() => report.value?.default_php ?? ""); @@ -357,6 +363,12 @@ function openCreateWordpress(): void { }); } +function openCreateComposer(framework: ComposerFramework): void { + void nextTick(() => { + composerCreateOpen.value = framework; + }); +} + async function onCreated(): Promise { await load({ force: true }); } @@ -672,6 +684,15 @@ async function shareSitePublicly(s: Site): Promise { New WordPress site… + + New CodeIgniter site… + + + New CakePHP site… + + + New Slim site… + Link existing site @@ -962,6 +983,21 @@ async function shareSitePublicly(s: Site): Promise { @created="onCreated" /> + + + diff --git a/bin/yerdd/src/codeigniter_url_sync.rs b/bin/yerdd/src/codeigniter_url_sync.rs new file mode 100644 index 00000000..c190dc14 --- /dev/null +++ b/bin/yerdd/src/codeigniter_url_sync.rs @@ -0,0 +1,131 @@ +//! Keeps a `CodeIgniter` 4 site's `app.baseURL` (in its `.env`) in step with +//! the scheme and domain yerd serves it on - the counterpart of +//! [`crate::wordpress_url_sync`], run from the same post-mutation hook. The +//! create wizard writes `app.baseURL` once; without this, toggling HTTPS or +//! changing the primary domain afterwards left every URL the app generates +//! pointing at the old address. +//! +//! Only an active `app.baseURL` line is rewritten: a commented-out or missing +//! setting is the user's choice and left alone. Best-effort: a failed write only +//! logs a warning and never fails the mutation it's attached to. + +use yerd_core::Site; + +use crate::state::DaemonState; + +/// Post-mutation hook: if `site` is a `CodeIgniter` 4 project with an active +/// `app.baseURL` in its `.env`, point it at the site's current URL. +pub async fn sync_base_url(site: &Site, state: &DaemonState) { + let root = site.document_root(); + if !root.join("spark").is_file() { + return; + } + let dotenv = root.join(".env"); + let Ok(current) = tokio::fs::read_to_string(&dotenv).await else { + return; + }; + + let host = state.router.read().await.primary_fqdn(site.name()); + let url = crate::public_url::site_url(state, &host, site.secure()).await; + let Some(updated) = rewrite_base_url(¤t, &format!("{url}/")) else { + return; + }; + if let Err(e) = tokio::fs::write(&dotenv, updated).await { + tracing::warn!( + site = %site.name(), + error = %e, + "couldn't sync CodeIgniter app.baseURL after a site change" + ); + } +} + +/// Replace the first active `app.baseURL` line with `base_url`. Pure - `None` +/// when there is no active line or it already matches. +fn rewrite_base_url(env: &str, base_url: &str) -> Option { + let wanted = format!("app.baseURL = '{base_url}'"); + let mut replaced = false; + let mut changed = false; + let mut lines: Vec<&str> = Vec::new(); + for line in env.lines() { + if !replaced && is_active_base_url(line) { + replaced = true; + changed = line.trim() != wanted; + lines.push(&wanted); + } else { + lines.push(line); + } + } + if !changed { + return None; + } + let mut out = lines.join("\n"); + if env.ends_with('\n') { + out.push('\n'); + } + Some(out) +} + +fn is_active_base_url(line: &str) -> bool { + let line = line.trim_start(); + !line.starts_with('#') + && line + .split_once('=') + .is_some_and(|(key, _)| key.trim() == "app.baseURL") +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn rewrites_the_active_base_url_line() { + let env = "CI_ENVIRONMENT = development\napp.baseURL = 'http://blog.test/'\n"; + assert_eq!( + rewrite_base_url(env, "https://blog.test/").unwrap(), + "CI_ENVIRONMENT = development\napp.baseURL = 'https://blog.test/'\n" + ); + } + + #[test] + fn keeps_a_missing_trailing_newline_missing() { + assert_eq!( + rewrite_base_url("app.baseURL = 'http://a.test/'", "https://a.test/").unwrap(), + "app.baseURL = 'https://a.test/'" + ); + } + + #[test] + fn leaves_commented_missing_and_matching_settings_alone() { + for env in [ + "# app.baseURL = 'http://blog.test/'\n", + "CI_ENVIRONMENT = development\n", + "app.baseURL = 'https://blog.test/'\n", + "", + ] { + assert_eq!(rewrite_base_url(env, "https://blog.test/"), None, "{env:?}"); + } + } + + #[test] + fn only_the_first_active_line_is_rewritten() { + let env = "app.baseURL = 'http://a.test/'\napp.baseURL = 'http://b.test/'\n"; + assert_eq!( + rewrite_base_url(env, "https://a.test/").unwrap(), + "app.baseURL = 'https://a.test/'\napp.baseURL = 'http://b.test/'\n" + ); + } + + #[test] + fn is_active_base_url_ignores_similar_keys() { + for (line, expected) in [ + ("app.baseURL = ''", true), + (" app.baseURL=''", true), + ("# app.baseURL = ''", false), + ("app.baseURLs = ''", false), + ("app.allowedHostnames = []", false), + ] { + assert_eq!(is_active_base_url(line), expected, "{line:?}"); + } + } +} diff --git a/bin/yerdd/src/create_site/composer_project.rs b/bin/yerdd/src/create_site/composer_project.rs new file mode 100644 index 00000000..1b42f3f4 --- /dev/null +++ b/bin/yerdd/src/create_site/composer_project.rs @@ -0,0 +1,410 @@ +//! `composer create-project` scaffolding for frameworks whose official starter +//! is a Composer project template (`CodeIgniter` 4, `CakePHP`, Slim). +//! +//! Preflight resolves PHP + Composer and builds the same per-job `PATH` the +//! Laravel job uses, so any `php`/`composer` the template's post-install +//! scripts shell out to runs on the requested version. Scaffolding runs +//! `composer create-project`; a per-framework post step then fixes up what the +//! template leaves unsuitable for local development; Registering reuses the +//! shared [`super::registration`]. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use tokio::sync::watch; + +use yerd_ipc::{CreateSiteSpec, Framework}; + +use super::{Composer, Outcome, StreamedOutcome}; +use crate::state::DaemonState; +use crate::tools; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Template { + Codeigniter, + Cakephp, + Slim, +} + +impl Template { + pub(super) fn from_framework(framework: &Framework) -> Option { + match framework { + Framework::Codeigniter => Some(Self::Codeigniter), + Framework::Cakephp => Some(Self::Cakephp), + Framework::Slim => Some(Self::Slim), + _ => None, + } + } + + fn package(self) -> &'static str { + match self { + Self::Codeigniter => "codeigniter4/appstarter", + Self::Cakephp => "cakephp/app", + Self::Slim => "slim/skeleton", + } + } +} + +/// Preflight + Scaffolding + post step + Registering for a Composer template. +#[allow(clippy::too_many_lines)] +pub(super) async fn run( + id: &str, + name: &str, + spec: &CreateSiteSpec, + template: Template, + job_dir: &Path, + state: &Arc, + mut cancel_rx: watch::Receiver, +) -> Outcome { + let dirs = &state.dirs; + let project_dir = spec.parent_dir.join(name); + + state.jobs.set_phase(id, "Preflight").await; + + let php_cli = crate::php_install::cli_binary_path(dirs, spec.php); + if !php_cli.is_file() { + return Outcome::Failed(format!( + "PHP {}.{} is not installed", + spec.php.major, spec.php.minor + )); + } + + let user_dirs = crate::tools::external::resolve_user_path() + .await + .unwrap_or_default(); + let data_bin = tools::bin_dir(dirs); + + let composer_phar = tools::composer::phar_path(dirs); + let Some(composer) = + super::resolve_composer(&composer_phar, &user_dirs, &data_bin, &dirs.data) + else { + return Outcome::Failed("Composer is not installed - install it first".to_owned()); + }; + + if let Err(msg) = super::check_target_dir(&project_dir) { + return Outcome::Failed(msg); + } + if let Err(msg) = super::probe_writable(&spec.parent_dir) { + return Outcome::Failed(msg); + } + + let job_bin = match super::build_job_bin(job_dir, &php_cli, composer.managed_phar()) { + Ok(b) => b, + Err(msg) => return Outcome::Failed(msg), + }; + let path_env = super::composed_path(&job_bin, &data_bin, &user_dirs); + let composer_home = tools::laravel::composer_home(dirs); + + state.jobs.set_phase(id, "Scaffolding").await; + let args = create_project_args(template, name); + state + .jobs + .push_log(id, format!("$ composer {CREATE_PROJECT} {}", args.join(" "))) + .await; + + let command = composer_command(&composer, &php_cli, args); + let scaffold = super::run_streamed( + id, + &command.program, + &[], + &command.entry_point, + &command.args, + &spec.parent_dir, + Some(&path_env), + Some(&composer_home), + None, + false, + None, + state, + &mut cancel_rx, + ) + .await; + match scaffold { + StreamedOutcome::Ok => {} + StreamedOutcome::Failed(msg) => { + let _ = std::fs::remove_dir_all(&project_dir); + return Outcome::Failed(msg); + } + StreamedOutcome::Cancelled => { + let _ = std::fs::remove_dir_all(&project_dir); + return Outcome::Cancelled; + } + } + if !project_dir.join("composer.json").is_file() { + let _ = std::fs::remove_dir_all(&project_dir); + return Outcome::Failed(format!( + "composer {CREATE_PROJECT} finished without creating {}", + project_dir.display() + )); + } + + let tld = state.config.lock().await.tld.as_str().to_owned(); + let url = crate::public_url::site_url(state, &format!("{name}.{tld}"), spec.secure).await; + + if template == Template::Codeigniter { + if let Err(msg) = write_codeigniter_env(&project_dir, &format!("{url}/")) { + state + .jobs + .push_log(id, format!("warning: could not write .env: {msg}")) + .await; + } + } + + state.jobs.set_phase(id, "Registering").await; + if let Err(msg) = + super::registration::register(name, &spec.parent_dir, &project_dir, spec, state).await + { + return Outcome::Failed(format!("scaffolded, but registration failed: {msg}")); + } + state.jobs.push_log(id, format!("serving {url}")).await; + Outcome::Succeeded +} + +const CREATE_PROJECT: &str = "create-project"; + +#[derive(Debug, PartialEq, Eq)] +struct Command { + program: PathBuf, + entry_point: PathBuf, + args: Vec, +} + +/// Map `composer create-project ` onto [`super::run_streamed`]'s +/// ` ` shape. Pure - unit-tested. +fn composer_command(composer: &Composer, php_cli: &Path, args: Vec) -> Command { + match composer { + Composer::Managed(phar) => Command { + program: php_cli.to_path_buf(), + entry_point: phar.clone(), + args: std::iter::once(CREATE_PROJECT.to_owned()).chain(args).collect(), + }, + Composer::External(bin) => Command { + program: bin.clone(), + entry_point: PathBuf::from(CREATE_PROJECT), + args, + }, + } +} + +/// The `composer create-project` arguments after the subcommand. Pure - +/// unit-tested. +/// +/// `--no-interaction` accepts each template's post-install defaults (`CakePHP` +/// asks whether to set folder permissions); `--no-ansi` keeps the plain-text +/// job log readable, as for `laravel new`. +fn create_project_args(template: Template, name: &str) -> Vec { + vec![ + "--no-interaction".to_owned(), + "--no-ansi".to_owned(), + "--prefer-dist".to_owned(), + template.package().to_owned(), + name.to_owned(), + ] +} + +/// `base_url` must end in `/`, which `CodeIgniter`'s `app.baseURL` requires. +fn write_codeigniter_env(project_dir: &Path, base_url: &str) -> Result<(), String> { + let dotenv = project_dir.join(".env"); + if dotenv.exists() { + return Ok(()); + } + let template = std::fs::read_to_string(project_dir.join("env")).unwrap_or_default(); + std::fs::write(&dotenv, codeigniter_env(&template, base_url)) + .map_err(|e| format!("{}: {e}", dotenv.display())) +} + +/// Rewrite `CodeIgniter`'s `env` template for local development. Pure - +/// unit-tested. +/// +/// The template ships every setting commented out, so the app would boot in +/// `production` mode (errors hidden) with an empty `app.baseURL`. The first +/// `CI_ENVIRONMENT` and `app.baseURL` lines, commented or not, are replaced; +/// either key missing from the template is appended instead. +fn codeigniter_env(template: &str, base_url: &str) -> String { + let env_line = "CI_ENVIRONMENT = development".to_owned(); + let url_line = format!("app.baseURL = '{base_url}'"); + let mut env_set = false; + let mut url_set = false; + let mut lines: Vec = Vec::new(); + for line in template.lines() { + match env_key(line) { + Some("CI_ENVIRONMENT") if !env_set => { + lines.push(env_line.clone()); + env_set = true; + } + Some("app.baseURL") if !url_set => { + lines.push(url_line.clone()); + url_set = true; + } + _ => lines.push(line.to_owned()), + } + } + if !env_set { + lines.push(env_line); + } + if !url_set { + lines.push(url_line); + } + let mut out = lines.join("\n"); + out.push('\n'); + out +} + +/// Key of a `key = value` line, active or commented out. `None` for prose. +fn env_key(line: &str) -> Option<&str> { + let (key, _) = line.trim_start().trim_start_matches('#').split_once('=')?; + let key = key.trim(); + (!key.is_empty() && !key.contains(char::is_whitespace)).then_some(key) +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing +)] +mod tests { + use super::*; + + #[test] + fn from_framework_maps_each_composer_framework() { + for (framework, template) in [ + (Framework::Codeigniter, Template::Codeigniter), + (Framework::Cakephp, Template::Cakephp), + (Framework::Slim, Template::Slim), + ] { + assert_eq!(Template::from_framework(&framework), Some(template)); + } + } + + #[test] + fn create_project_args_per_template() { + for (template, package) in [ + (Template::Codeigniter, "codeigniter4/appstarter"), + (Template::Cakephp, "cakephp/app"), + (Template::Slim, "slim/skeleton"), + ] { + assert_eq!( + create_project_args(template, "app"), + vec!["--no-interaction", "--no-ansi", "--prefer-dist", package, "app"], + "{template:?}" + ); + } + } + + #[test] + fn composer_command_runs_managed_phar_under_job_php() { + let cmd = composer_command( + &Composer::Managed(PathBuf::from("/data/composer.phar")), + Path::new("/data/php/8.3/bin/php"), + vec!["--no-ansi".to_owned()], + ); + assert_eq!( + cmd, + Command { + program: PathBuf::from("/data/php/8.3/bin/php"), + entry_point: PathBuf::from("/data/composer.phar"), + args: vec!["create-project".to_owned(), "--no-ansi".to_owned()], + } + ); + } + + #[test] + fn composer_command_executes_external_composer_directly() { + let cmd = composer_command( + &Composer::External(PathBuf::from("/home/me/.local/share/mise/shims/composer")), + Path::new("/data/php/8.3/bin/php"), + vec!["--no-ansi".to_owned()], + ); + assert_eq!( + cmd, + Command { + program: PathBuf::from("/home/me/.local/share/mise/shims/composer"), + entry_point: PathBuf::from("create-project"), + args: vec!["--no-ansi".to_owned()], + } + ); + } + + #[test] + fn env_key_reads_active_and_commented_settings() { + for (line, expected) in [ + ("CI_ENVIRONMENT = production", Some("CI_ENVIRONMENT")), + ("# CI_ENVIRONMENT = production", Some("CI_ENVIRONMENT")), + (" #app.baseURL = ''", Some("app.baseURL")), + ( + "# database.default.hostname = localhost", + Some("database.default.hostname"), + ), + ("# If you use this file, rename it to .env", None), + ("# Set it = to something", None), + ("#--------------------------------", None), + ("", None), + ] { + assert_eq!(env_key(line), expected, "{line:?}"); + } + } + + #[test] + fn codeigniter_env_replaces_commented_template_lines() { + let template = "\ +#-------------------------------------------------------------------- +# ENVIRONMENT +#-------------------------------------------------------------------- + +# CI_ENVIRONMENT = production + +#-------------------------------------------------------------------- +# APP +#-------------------------------------------------------------------- + +# app.baseURL = '' +# app.forceGlobalSecureRequests = false +"; + let out = codeigniter_env(template, "https://blog.test/"); + assert!(out.contains("\nCI_ENVIRONMENT = development\n"), "{out}"); + assert!(out.contains("\napp.baseURL = 'https://blog.test/'\n"), "{out}"); + assert!(out.contains("# app.forceGlobalSecureRequests = false\n")); + assert!(!out.contains("production")); + assert_eq!(out.lines().count(), template.lines().count()); + } + + #[test] + fn codeigniter_env_replaces_only_the_first_occurrence() { + let out = codeigniter_env( + "# CI_ENVIRONMENT = production\n# CI_ENVIRONMENT = testing\n", + "http://a.test/", + ); + assert_eq!( + out, + "CI_ENVIRONMENT = development\n# CI_ENVIRONMENT = testing\napp.baseURL = 'http://a.test/'\n" + ); + } + + #[test] + fn codeigniter_env_appends_missing_keys() { + assert_eq!( + codeigniter_env("", "http://a.test/"), + "CI_ENVIRONMENT = development\napp.baseURL = 'http://a.test/'\n" + ); + } + + #[test] + fn write_codeigniter_env_creates_dotenv_from_template() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("env"), "# CI_ENVIRONMENT = production\n").unwrap(); + write_codeigniter_env(tmp.path(), "http://a.test/").unwrap(); + let dotenv = std::fs::read_to_string(tmp.path().join(".env")).unwrap(); + assert!(dotenv.starts_with("CI_ENVIRONMENT = development\n")); + } + + #[test] + fn write_codeigniter_env_keeps_existing_dotenv() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join(".env"), "KEEP=1\n").unwrap(); + write_codeigniter_env(tmp.path(), "http://a.test/").unwrap(); + let dotenv = std::fs::read_to_string(tmp.path().join(".env")).unwrap(); + assert_eq!(dotenv, "KEEP=1\n"); + } +} diff --git a/bin/yerdd/src/create_site/laravel.rs b/bin/yerdd/src/create_site/laravel.rs index 8c4606bd..31cd3e36 100644 --- a/bin/yerdd/src/create_site/laravel.rs +++ b/bin/yerdd/src/create_site/laravel.rs @@ -49,13 +49,11 @@ pub(super) async fn run( let data_root = &dirs.data; let composer_phar = tools::composer::phar_path(dirs); - let composer_managed = composer_phar.is_file(); - if !composer_managed - && crate::tools::external::find_in_path(&user_dirs, "composer", &data_bin, data_root) - .is_none() - { + let Some(composer) = + super::resolve_composer(&composer_phar, &user_dirs, &data_bin, data_root) + else { return Outcome::Failed("Composer is not installed - install it first".to_owned()); - } + }; let managed_installer = tools::laravel::installer_bin(dirs); let installer_bin = if managed_installer.is_file() { @@ -81,15 +79,11 @@ pub(super) async fn run( return Outcome::Failed(msg); } - let job_bin = match build_job_bin( - job_dir, - &php_cli, - composer_managed.then_some(composer_phar.as_path()), - ) { + let job_bin = match super::build_job_bin(job_dir, &php_cli, composer.managed_phar()) { Ok(b) => b, Err(msg) => return Outcome::Failed(msg), }; - let path_env = composed_path(&job_bin, &data_bin, &user_dirs); + let path_env = super::composed_path(&job_bin, &data_bin, &user_dirs); let composer_home = tools::laravel::composer_home(dirs); if needs_git(options) && !git_available(&path_env).await { @@ -139,10 +133,9 @@ pub(super) async fn run( { return Outcome::Failed(format!("scaffolded, but registration failed: {msg}")); } - state - .jobs - .push_log(id, format!("serving https://{name}.test")) - .await; + let tld = state.config.lock().await.tld.as_str().to_owned(); + let url = crate::public_url::site_url(state, &format!("{name}.{tld}"), spec.secure).await; + state.jobs.push_log(id, format!("serving {url}")).await; Outcome::Succeeded } @@ -233,19 +226,6 @@ async fn ensure_js_runtime( super::ensure_tool(id, tool, user_dirs, state).await } -/// Compose `PATH` = ` : <{data}/bin> : : `. -/// The user's resolved PATH is appended so externally-installed -/// composer/node/bun/git/laravel are findable, while the per-job bin (managed -/// `php`) and Yerd shims keep precedence. -fn composed_path(job_bin: &Path, data_bin: &Path, user_dirs: &[PathBuf]) -> std::ffi::OsString { - let mut entries = vec![job_bin.to_path_buf(), data_bin.to_path_buf()]; - entries.extend(user_dirs.iter().cloned()); - if let Some(existing) = std::env::var_os("PATH") { - entries.extend(std::env::split_paths(&existing)); - } - std::env::join_paths(entries).unwrap_or_else(|_| std::ffi::OsString::from(job_bin)) -} - /// `git --version` resolves on the composed PATH. async fn git_available(path_env: &std::ffi::OsString) -> bool { tokio::process::Command::new("git") @@ -259,58 +239,6 @@ async fn git_available(path_env: &std::ffi::OsString) -> bool { .is_ok_and(|s| s.success()) } -/// Single-quote a path for safe inclusion in a `/bin/sh` script, escaping any -/// embedded single quotes (`'` → `'\''`). Without this a data dir containing a -/// `'` (e.g. `/Users/o'brien/…`) would produce a broken wrapper script. -#[cfg(unix)] -fn sh_quote(p: &Path) -> String { - format!("'{}'", p.to_string_lossy().replace('\'', "'\\''")) -} - -/// Build `{job_dir}/bin` containing a `php` symlink to the chosen version and, -/// when `composer_phar` is `Some` (Yerd-managed Composer), a `composer` wrapper -/// that runs that same PHP so the installer's nested `composer create-project` -/// uses the requested runtime (Composer derives its child PHP from `PHP_BINARY`). -/// When `None` (external Composer), no wrapper is written - Composer is found on -/// the composed PATH and runs under the managed `php` via its shebang. Unix-only. -#[cfg(unix)] -fn build_job_bin( - job_dir: &Path, - php_cli: &Path, - composer_phar: Option<&Path>, -) -> Result { - use std::os::unix::fs::PermissionsExt; - - let bin = job_dir.join("bin"); - std::fs::create_dir_all(&bin).map_err(|e| format!("{}: {e}", bin.display()))?; - - let php_link = bin.join("php"); - let _ = std::fs::remove_file(&php_link); - std::os::unix::fs::symlink(php_cli, &php_link).map_err(|e| format!("link php: {e}"))?; - - if let Some(phar) = composer_phar { - let composer = bin.join("composer"); - let script = format!( - "#!/bin/sh\nexec {} {} \"$@\"\n", - sh_quote(php_cli), - sh_quote(phar) - ); - std::fs::write(&composer, script).map_err(|e| format!("write composer wrapper: {e}"))?; - std::fs::set_permissions(&composer, std::fs::Permissions::from_mode(0o755)) - .map_err(|e| format!("chmod composer wrapper: {e}"))?; - } - Ok(bin) -} - -#[cfg(not(unix))] -fn build_job_bin( - _job_dir: &Path, - _php_cli: &Path, - _composer_phar: Option<&Path>, -) -> Result { - Err("site creation is not yet supported on this platform".to_owned()) -} - #[cfg(test)] #[allow( clippy::unwrap_used, @@ -472,64 +400,4 @@ mod tests { o.starter_kit = StarterKit::Community("acme/kit".to_owned()); assert!(needs_git(&o)); } - - #[cfg(unix)] - #[test] - fn composed_path_puts_job_bin_first() { - let job_bin = Path::new("/jobs/abc/bin"); - let data_bin = Path::new("/data/bin"); - let user = vec![PathBuf::from("/opt/homebrew/bin")]; - let composed = composed_path(job_bin, data_bin, &user); - let entries: Vec = std::env::split_paths(&composed).collect(); - assert_eq!(entries.first().unwrap(), job_bin); - assert_eq!(entries.get(1).unwrap(), data_bin); - assert!(entries.iter().any(|p| p == Path::new("/opt/homebrew/bin"))); - } - - #[cfg(unix)] - #[test] - fn sh_quote_escapes_embedded_single_quotes() { - assert_eq!(sh_quote(Path::new("/Users/obrien")), "'/Users/obrien'"); - assert_eq!( - sh_quote(Path::new("/Users/o'brien/data")), - "'/Users/o'\\''brien/data'" - ); - } - - #[cfg(unix)] - #[test] - fn build_job_bin_links_php_and_writes_composer_wrapper() { - use std::os::unix::fs::PermissionsExt; - let tmp = tempfile::tempdir().unwrap(); - let job_dir = tmp.path().join("job"); - let php = tmp.path().join("php-bin"); - std::fs::write(&php, b"#!fake-php").unwrap(); - let phar = tmp.path().join("composer.phar"); - std::fs::write(&phar, b"phar").unwrap(); - - let bin = build_job_bin(&job_dir, &php, Some(phar.as_path())).unwrap(); - assert_eq!(std::fs::read_link(bin.join("php")).unwrap(), php); - let wrapper = std::fs::read_to_string(bin.join("composer")).unwrap(); - assert!(wrapper.starts_with("#!/bin/sh\n")); - assert!(wrapper.contains(&php.to_string_lossy().into_owned())); - assert!(wrapper.contains(&phar.to_string_lossy().into_owned())); - let mode = std::fs::metadata(bin.join("composer")) - .unwrap() - .permissions() - .mode(); - assert_eq!(mode & 0o111, 0o111, "wrapper should be executable"); - } - - #[cfg(unix)] - #[test] - fn build_job_bin_without_phar_writes_no_composer_wrapper() { - let tmp = tempfile::tempdir().unwrap(); - let job_dir = tmp.path().join("job"); - let php = tmp.path().join("php-bin"); - std::fs::write(&php, b"#!fake-php").unwrap(); - - let bin = build_job_bin(&job_dir, &php, None).unwrap(); - assert!(bin.join("php").exists()); - assert!(!bin.join("composer").exists()); - } } diff --git a/bin/yerdd/src/create_site/mod.rs b/bin/yerdd/src/create_site/mod.rs index 56475168..6d1c46cb 100644 --- a/bin/yerdd/src/create_site/mod.rs +++ b/bin/yerdd/src/create_site/mod.rs @@ -1,5 +1,5 @@ -//! `CreateSite` - scaffold a new project (`laravel new` or WP-CLI) then -//! register it. +//! `CreateSite` - scaffold a new project (`laravel new`, WP-CLI or +//! `composer create-project`) then register it. //! //! Scaffolding runs far longer than one request/response round-trip and streams //! output, so this runs as a background [job](crate::jobs): [`start`] spawns the @@ -8,10 +8,11 @@ //! //! This module holds the framework-agnostic job orchestration (name //! reservation, the per-job scratch dir, `JobRegistry` wiring, the streamed- -//! process runner, registration); [`laravel`] and [`wordpress`] hold each -//! framework's own scaffolding body, dispatched on `spec.framework` from -//! [`run_inner`]. +//! process runner, registration); [`laravel`], [`wordpress`] and +//! [`composer_project`] hold each framework's own scaffolding body, dispatched +//! on `spec.framework` from [`run_inner`]. +mod composer_project; mod laravel; mod registration; mod wordpress; @@ -39,7 +40,11 @@ pub async fn start(spec: CreateSiteSpec, state: Arc) -> Response { // The enum is `#[non_exhaustive]`, so a catch-all is required and guards // future variants. match &spec.framework { - Framework::Laravel { .. } | Framework::Wordpress { .. } => {} + Framework::Laravel { .. } + | Framework::Wordpress { .. } + | Framework::Codeigniter + | Framework::Cakephp + | Framework::Slim => {} _ => return error(ErrorCode::Internal, "unsupported framework".to_owned()), } @@ -122,7 +127,12 @@ async fn run_inner( Framework::Wordpress { options } => { wordpress::run(id, name, spec, options, state, cancel_rx).await } - _ => Outcome::Failed("unsupported framework".to_owned()), + framework => match composer_project::Template::from_framework(framework) { + Some(template) => { + composer_project::run(id, name, spec, template, job_dir, state, cancel_rx).await + } + None => Outcome::Failed("unsupported framework".to_owned()), + }, } } @@ -419,6 +429,104 @@ fn kill_group(pgid: Option, signal: nix::sys::signal::Signal) { } } +/// The Composer a job runs: Yerd's managed phar, or one from the user's PATH. +#[derive(Debug, PartialEq, Eq)] +enum Composer { + Managed(PathBuf), + /// A `composer` from the user's PATH. Executed directly rather than as a + /// PHP script, because it may be a shell shim (mise, asdf, Nix); its own + /// `php` still resolves to the job's PHP through the composed PATH. + External(PathBuf), +} + +impl Composer { + fn managed_phar(&self) -> Option<&Path> { + match self { + Self::Managed(phar) => Some(phar.as_path()), + Self::External(_) => None, + } + } +} + +fn resolve_composer( + managed_phar: &Path, + user_dirs: &[PathBuf], + data_bin: &Path, + data_root: &Path, +) -> Option { + if managed_phar.is_file() { + return Some(Composer::Managed(managed_phar.to_path_buf())); + } + crate::tools::external::find_in_path(user_dirs, "composer", data_bin, data_root) + .map(Composer::External) +} + +/// Compose `PATH` = ` : <{data}/bin> : : `. +/// The user's resolved PATH is appended so externally-installed +/// composer/node/bun/git/laravel are findable, while the per-job bin (managed +/// `php`) and Yerd shims keep precedence. +fn composed_path(job_bin: &Path, data_bin: &Path, user_dirs: &[PathBuf]) -> std::ffi::OsString { + let mut entries = vec![job_bin.to_path_buf(), data_bin.to_path_buf()]; + entries.extend(user_dirs.iter().cloned()); + if let Some(existing) = std::env::var_os("PATH") { + entries.extend(std::env::split_paths(&existing)); + } + std::env::join_paths(entries).unwrap_or_else(|_| std::ffi::OsString::from(job_bin)) +} + +/// Single-quote a path for safe inclusion in a `/bin/sh` script, escaping any +/// embedded single quotes (`'` → `'\''`). Without this a data dir containing a +/// `'` (e.g. `/Users/o'brien/…`) would produce a broken wrapper script. +#[cfg(unix)] +fn sh_quote(p: &Path) -> String { + format!("'{}'", p.to_string_lossy().replace('\'', "'\\''")) +} + +/// Build `{job_dir}/bin` containing a `php` symlink to the chosen version and, +/// when `composer_phar` is `Some` (Yerd-managed Composer), a `composer` wrapper +/// that runs that same PHP so every `composer` call on the job's PATH (the +/// Laravel installer's nested `create-project` included) uses the requested +/// runtime (Composer derives its child PHP from `PHP_BINARY`). +/// When `None` (external Composer), no wrapper is written - Composer is found on +/// the composed PATH and runs under the managed `php` via its shebang. Unix-only. +#[cfg(unix)] +fn build_job_bin( + job_dir: &Path, + php_cli: &Path, + composer_phar: Option<&Path>, +) -> Result { + use std::os::unix::fs::PermissionsExt; + + let bin = job_dir.join("bin"); + std::fs::create_dir_all(&bin).map_err(|e| format!("{}: {e}", bin.display()))?; + + let php_link = bin.join("php"); + let _ = std::fs::remove_file(&php_link); + std::os::unix::fs::symlink(php_cli, &php_link).map_err(|e| format!("link php: {e}"))?; + + if let Some(phar) = composer_phar { + let composer = bin.join("composer"); + let script = format!( + "#!/bin/sh\nexec {} {} \"$@\"\n", + sh_quote(php_cli), + sh_quote(phar) + ); + std::fs::write(&composer, script).map_err(|e| format!("write composer wrapper: {e}"))?; + std::fs::set_permissions(&composer, std::fs::Permissions::from_mode(0o755)) + .map_err(|e| format!("chmod composer wrapper: {e}"))?; + } + Ok(bin) +} + +#[cfg(not(unix))] +fn build_job_bin( + _job_dir: &Path, + _php_cli: &Path, + _composer_phar: Option<&Path>, +) -> Result { + Err("site creation is not yet supported on this platform".to_owned()) +} + fn error(code: ErrorCode, message: String) -> Response { Response::Error { code, message } } @@ -570,6 +678,25 @@ mod tests { } } + #[tokio::test] + async fn start_accepts_composer_frameworks() { + for framework in [Framework::Codeigniter, Framework::Cakephp, Framework::Slim] { + let tmp = tempfile::tempdir().unwrap(); + let state = Arc::new(crate::test_support::state_in(tmp.path())); + let spec = CreateSiteSpec { + name: "app".to_owned(), + parent_dir: tmp.path().to_path_buf(), + php: PhpVersion::new(8, 3), + secure: false, + framework, + }; + match start(spec, state).await { + Response::JobStarted { .. } => {} + other => panic!("expected JobStarted, got {other:?}"), + } + } + } + #[tokio::test] async fn ensure_managed_tool_short_circuits_on_managed_marker() { let tmp = tempfile::tempdir().unwrap(); @@ -609,4 +736,104 @@ mod tests { .unwrap_err(); assert!(err.contains("failed to install WP-CLI"), "{err}"); } + + #[cfg(unix)] + #[test] + fn composed_path_puts_job_bin_first() { + let job_bin = Path::new("/jobs/abc/bin"); + let data_bin = Path::new("/data/bin"); + let user = vec![PathBuf::from("/opt/homebrew/bin")]; + let composed = composed_path(job_bin, data_bin, &user); + let entries: Vec = std::env::split_paths(&composed).collect(); + assert_eq!(entries.first().unwrap(), job_bin); + assert_eq!(entries.get(1).unwrap(), data_bin); + assert!(entries.iter().any(|p| p == Path::new("/opt/homebrew/bin"))); + } + + #[cfg(unix)] + #[test] + fn sh_quote_escapes_embedded_single_quotes() { + assert_eq!(sh_quote(Path::new("/Users/obrien")), "'/Users/obrien'"); + assert_eq!( + sh_quote(Path::new("/Users/o'brien/data")), + "'/Users/o'\\''brien/data'" + ); + } + + #[cfg(unix)] + #[test] + fn build_job_bin_links_php_and_writes_composer_wrapper() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let job_dir = tmp.path().join("job"); + let php = tmp.path().join("php-bin"); + std::fs::write(&php, b"#!fake-php").unwrap(); + let phar = tmp.path().join("composer.phar"); + std::fs::write(&phar, b"phar").unwrap(); + + let bin = build_job_bin(&job_dir, &php, Some(phar.as_path())).unwrap(); + assert_eq!(std::fs::read_link(bin.join("php")).unwrap(), php); + let wrapper = std::fs::read_to_string(bin.join("composer")).unwrap(); + assert!(wrapper.starts_with("#!/bin/sh\n")); + assert!(wrapper.contains(&php.to_string_lossy().into_owned())); + assert!(wrapper.contains(&phar.to_string_lossy().into_owned())); + let mode = std::fs::metadata(bin.join("composer")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o111, 0o111, "wrapper should be executable"); + } + + #[cfg(unix)] + #[test] + fn build_job_bin_without_phar_writes_no_composer_wrapper() { + let tmp = tempfile::tempdir().unwrap(); + let job_dir = tmp.path().join("job"); + let php = tmp.path().join("php-bin"); + std::fs::write(&php, b"#!fake-php").unwrap(); + + let bin = build_job_bin(&job_dir, &php, None).unwrap(); + assert!(bin.join("php").exists()); + assert!(!bin.join("composer").exists()); + } + + #[test] + fn resolve_composer_prefers_managed_phar() { + let tmp = tempfile::tempdir().unwrap(); + let phar = tmp.path().join("composer.phar"); + std::fs::write(&phar, b"phar").unwrap(); + let got = resolve_composer(&phar, &[], &tmp.path().join("bin"), tmp.path()); + assert_eq!(got, Some(Composer::Managed(phar))); + } + + #[cfg(unix)] + #[test] + fn resolve_composer_falls_back_to_external_on_path() { + use std::os::unix::fs::PermissionsExt as _; + + let tmp = tempfile::tempdir().unwrap(); + let ext = tmp.path().join("extbin"); + std::fs::create_dir_all(&ext).unwrap(); + let shim = ext.join("composer"); + std::fs::write(&shim, b"#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let data = tmp.path().join("data"); + std::fs::create_dir_all(&data).unwrap(); + + let got = resolve_composer(&data.join("composer.phar"), &[ext], &data.join("bin"), &data); + assert_eq!(got, Some(Composer::External(shim))); + } + + #[test] + fn resolve_composer_none_without_managed_or_external() { + let tmp = tempfile::tempdir().unwrap(); + let got = resolve_composer( + &tmp.path().join("missing.phar"), + &[], + &tmp.path().join("bin"), + tmp.path(), + ); + assert_eq!(got, None); + } } diff --git a/bin/yerdd/src/ipc_server.rs b/bin/yerdd/src/ipc_server.rs index f98d3e0e..60ffd952 100644 --- a/bin/yerdd/src/ipc_server.rs +++ b/bin/yerdd/src/ipc_server.rs @@ -2638,6 +2638,7 @@ pub(crate) async fn handle_mutation(req: Request, state: &DaemonState) -> Respon if let Some(site) = site_after { crate::wordpress_url_sync::sync_site_url(&site, state).await; + crate::codeigniter_url_sync::sync_base_url(&site, state).await; } tracing::info!(summary = %applied.summary, "applied mutation"); @@ -2645,7 +2646,7 @@ pub(crate) async fn handle_mutation(req: Request, state: &DaemonState) -> Respon } /// The post-mutation site to run [`crate::wordpress_url_sync::sync_site_url`] -/// against: `SetSecure` (which flips the scheme) plus every domain mutation +/// and [`crate::codeigniter_url_sync::sync_base_url`] against: `SetSecure` (which flips the scheme) plus every domain mutation /// (each of which can change the primary domain a WordPress install should /// advertise). `AddDomain` is included, not just `SetPrimaryDomain`/`ResetDomains` /// /`RemoveDomain`: re-adding a previously-suppressed apex when the delta holds no diff --git a/bin/yerdd/src/lib.rs b/bin/yerdd/src/lib.rs index f312e2dc..1fe26954 100644 --- a/bin/yerdd/src/lib.rs +++ b/bin/yerdd/src/lib.rs @@ -14,6 +14,7 @@ pub mod ansi; pub mod args; pub mod backend_resolver; pub mod cert_store; +pub mod codeigniter_url_sync; pub mod create_site; pub mod db_admin; pub mod detect_cache; @@ -28,6 +29,7 @@ pub mod laravel_detect; pub mod mutate; pub mod php_install; pub mod php_updates; +pub mod public_url; pub mod secure_fs; pub mod self_update; pub mod service_install; diff --git a/bin/yerdd/src/public_url.rs b/bin/yerdd/src/public_url.rs new file mode 100644 index 00000000..74210831 --- /dev/null +++ b/bin/yerdd/src/public_url.rs @@ -0,0 +1,71 @@ +//! The URL a browser reaches a site on, for the URLs the daemon writes into +//! projects and job logs. Mirrors the GUI's `siteUrl`: the bound port is +//! included only on a rootless fallback (8080/8443) with no 80/443 redirect in +//! front of it. + +use crate::state::DaemonState; + +/// `scheme://host[:port]` (no trailing slash) for `host` served with `secure`, +/// from the daemon's bound ports and the live port-redirect state. +pub async fn site_url(state: &DaemonState, host: &str, secure: bool) -> String { + let (bound, default) = if secure { + (state.https.bound, 443) + } else { + (state.http.bound, 80) + }; + let redirected = tokio::task::spawn_blocking(|| { + use yerd_platform::PortRedirector; + yerd_platform::ActivePortRedirector::new().is_active() + }) + .await + .ok() + .flatten(); + format_url(host, secure, url_port(bound, default, redirected == Some(true))) +} + +/// No port when redirected, unbound (`0`) or the scheme default. Pure. +fn url_port(bound: u16, default: u16, redirected: bool) -> Option { + (!redirected && bound != 0 && bound != default).then_some(bound) +} + +fn format_url(host: &str, secure: bool, port: Option) -> String { + let scheme = if secure { "https" } else { "http" }; + let port = port.map(|p| format!(":{p}")).unwrap_or_default(); + format!("{scheme}://{host}{port}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn url_port_only_for_unredirected_non_default_ports() { + for (bound, default, redirected, expected) in [ + (443, 443, false, None), + (80, 80, false, None), + (8443, 443, false, Some(8443)), + (8080, 80, false, Some(8080)), + (8443, 443, true, None), + (0, 443, false, None), + ] { + assert_eq!( + url_port(bound, default, redirected), + expected, + "{bound} {default} {redirected}" + ); + } + } + + #[test] + fn format_url_uses_scheme_host_and_port() { + for (host, secure, port, expected) in [ + ("blog.test", true, None, "https://blog.test"), + ("blog.test", false, None, "http://blog.test"), + ("blog.dev.local", true, None, "https://blog.dev.local"), + ("blog.test", true, Some(8443), "https://blog.test:8443"), + ("blog.test", false, Some(8080), "http://blog.test:8080"), + ] { + assert_eq!(format_url(host, secure, port), expected); + } + } +} diff --git a/crates/yerd-ipc/src/create.rs b/crates/yerd-ipc/src/create.rs index ca26a228..6e157618 100644 --- a/crates/yerd-ipc/src/create.rs +++ b/crates/yerd-ipc/src/create.rs @@ -64,6 +64,15 @@ pub enum Framework { /// WordPress-specific installer options. options: WordPressOptions, }, + /// Scaffold a `CodeIgniter` 4 app via `composer create-project + /// codeigniter4/appstarter`. Spelled `Codeigniter` so the wire tag is + /// `"codeigniter"`, same reason as [`Framework::Wordpress`]. + Codeigniter, + /// Scaffold a `CakePHP` app via `composer create-project cakephp/app`. + /// Spelled `Cakephp` so the wire tag is `"cakephp"`. + Cakephp, + /// Scaffold a Slim 4 app via `composer create-project slim/skeleton`. + Slim, } /// Options mapped onto `laravel new` flags. diff --git a/crates/yerd-ipc/tests/wire_stability.rs b/crates/yerd-ipc/tests/wire_stability.rs index a8d12c87..73059527 100644 --- a/crates/yerd-ipc/tests/wire_stability.rs +++ b/crates/yerd-ipc/tests/wire_stability.rs @@ -2679,6 +2679,32 @@ fn request_create_site_wordpress_byte_shape() { assert_eq!(serde_json::from_str::(&s).unwrap(), r); } +#[test] +fn request_create_site_composer_frameworks_byte_shape() { + use yerd_ipc::{CreateSiteSpec, Framework}; + for (framework, tag) in [ + (Framework::Codeigniter, "codeigniter"), + (Framework::Cakephp, "cakephp"), + (Framework::Slim, "slim"), + ] { + let r = Request::CreateSite { + spec: CreateSiteSpec { + name: "app".into(), + parent_dir: PathBuf::from("/srv"), + php: PhpVersion::new(8, 3), + secure: false, + framework, + }, + }; + let s = serde_json::to_string(&r).unwrap(); + let expected = format!( + r#"{{"type":"create_site","spec":{{"name":"app","parent_dir":"/srv","php":"8.3","secure":false,"framework":{{"framework":"{tag}"}}}}}}"# + ); + assert_eq!(s, expected); + assert_eq!(serde_json::from_str::(&s).unwrap(), r); + } +} + #[test] fn request_job_status_byte_shape() { let r = Request::JobStatus { diff --git a/docs/developer/binaries/yerdd.md b/docs/developer/binaries/yerdd.md index 6919e60d..7496b4a1 100644 --- a/docs/developer/binaries/yerdd.md +++ b/docs/developer/binaries/yerdd.md @@ -100,6 +100,7 @@ The daemon's modules (`src/lib.rs` re-exports each as `pub mod`): | `state` | `DaemonState` - the shared config + router + lifecycle channel. | | `ipc_server` | IPC accept loop and per-request dispatch. | | `cert_store` | `DaemonCertStore` - per-SNI leaf issuance/cache for the proxy. | +| `codeigniter_url_sync` | Keeps an active `app.baseURL` in a `CodeIgniter` 4 site's `.env` in sync with its HTTPS toggle and primary domain. | | `backend_resolver` | `DaemonBackendResolver` - routes a `Site` to a live FPM pool. | | `detect_cache` | `DetectCache` - memoises web-root detection per project, keyed on a freshness stamp. | | `fs_watch` | Debounced filesystem watcher that re-scans parked roots as projects appear/change. | @@ -107,11 +108,12 @@ The daemon's modules (`src/lib.rs` re-exports each as `pub mod`): | `site_domains` | Infallible, collision-resolving router builder (`build`) plus `collisions`, which reports the losing side of each domain clash (surfaced as `StatusReport.shadows`). Resolves domains for whole-host proxies as well as sites - every site is considered before any proxy. | | `php_install` | Download + unpack prebuilt PHP builds; `reqwest` downloader. | | `php_updates` | PHP update poller + cache (notify-only). | +| `public_url` | The browser-facing URL for a site (scheme, host, and the bound port on an unredirected rootless fallback), mirroring the GUI's `siteUrl`. | | `self_update` | Yerd self-update poller: fetches the GitHub Releases API, decides via the pure `yerd-update` crate, and persists a snapshot (`checked_at` + decision) both to disk and in `DaemonState` (notify-only). | | `dump_server` | Loopback TCP server reading newline-delimited JSON dump frames from the native `yerd-dump` extension into a bounded ring buffer; serves the ring to the GUI over IPC (`ListDumps`/`DumpsStatus`/…). | | `ext_install` | Downloads + SHA-256-verifies native PHP extension `.so`s per installed PHP version (from the `forjedio/yerd-php-ext` releases) into `{data}/php-ext/php-/`. An `ExtSpec` abstraction drives one fetch loop for **both** `yerd-dump` (`DUMP_SPEC`, gated on dumps) and `pcov` (`PCOV_SPEC`, ungated) - two manifests, one release. | | `tools` | Dev-tool installers (Composer, Node, Bun, the Laravel installer, WP-CLI): download/build + verify into `{data}/tools//` and reconcile their `{data}/bin` shims. See [Dev-tool installers](../dev-tools). | -| `create_site` | Site-scaffolding job bodies for the GUI wizard (`laravel.rs`, `wordpress.rs`) - see [WordPress support](#wordpress-support) below. | +| `create_site` | Site-scaffolding job bodies for the GUI wizards (`laravel.rs`, `wordpress.rs`, and `composer_project.rs` for the CodeIgniter/CakePHP/Slim `composer create-project` templates) - see [WordPress support](#wordpress-support) below. | | `wordpress_detect` | Narrow marker-file check for whether a site is a `WordPress` install (`wp-config.php`/`wp-load.php`). | | `wordpress_login` | One-click `WordPress` admin login: token registry + the `auto_prepend_file` bootstrap script. | | `wordpress_url_sync` | Keeps a `WordPress` site's own `siteurl`/`home` options in sync with its HTTP/HTTPS toggle. | @@ -240,7 +242,7 @@ After `run_until_shutdown` returns, the signal task is **aborted** rather than a - **Dumps (Laravel telemetry):** `ListDumps` (pages the ring), `ClearDumps`, `DeleteDump`, `DumpsStatus`, `SetDumpsEnabled` (first enable fetches the `.so` and restarts started pools), `SetDumpsPort` (test-binds then triggers a hot rebind), `SetDumpsPersist`, `SetDumpFeature` → `dump_server::*`. - **Mail capture:** `ListMails`, `GetMail`, `ClearMails`, `DeleteMails`, `MarkMailsRead` (marks the given mails read in the store), `SetMailPort`, `SetMailEnabled` (port/enabled persist to config and take effect on the next restart - no hot rebind) → the `mail_store` / `set_mail_*` handlers. `Status` reports the store's total and unread counts via `MailStatus`. - **Dev tools:** `ListTools` (pure fs status), `InstallTool`/`UninstallTool` → `tools::*` then a `{data}/bin` shim reconcile. See [Dev-tool installers](../dev-tools). -- **WordPress:** `CreateSite` (WordPress or Laravel spec) → a background job under `create_site::*`, polled via `JobStatus`. `MintWordpressLoginToken`, `SetWordpressAutoLogin` → `handle_mutation`, `WordpressAdminUsers`, `AvailableWordpressVersions` → `wordpress_login::*` / `wordpress_users::*` / `wordpress_versions::*`. See [WordPress support](#wordpress-support) above. +- **WordPress:** `CreateSite` (Laravel, WordPress, CodeIgniter, CakePHP or Slim spec) → a background job under `create_site::*`, polled via `JobStatus`. `MintWordpressLoginToken`, `SetWordpressAutoLogin` → `handle_mutation`, `WordpressAdminUsers`, `AvailableWordpressVersions` → `wordpress_login::*` / `wordpress_users::*` / `wordpress_versions::*`. See [WordPress support](#wordpress-support) above. - **Lifecycle:** `RestartDaemon` (Unix only). The dispatch also routes the **services / database-admin** families (`ListServices`, `InstallService`, `StartService`/`StopService`/`RestartService`, `CreateDatabase`/`ListDatabases`/`DropDatabase`/`BackupDatabase`/`RestoreDatabase`, …) to `services::*` / `db_admin::*`. diff --git a/docs/guide/desktop-app.md b/docs/guide/desktop-app.md index ad14cdea..d72eb8ac 100644 --- a/docs/guide/desktop-app.md +++ b/docs/guide/desktop-app.md @@ -69,7 +69,7 @@ The card itself keeps only the inline shortcuts - open, the HTTPS lock, and that The filter box above the list matches **any** domain a site answers, not just its name - typing `admin.` finds `codestash.test` when its only match is an added `admin.codestash.test`. -Sites can also be organized into named, reorderable groups shown as collapsible sections on this page; see [Sites](./sites) for the full walkthrough. The **Create** menu at the top of the page offers New Laravel site…, New WordPress site…, Link existing site, Park folder, and New group…. +Sites can also be organized into named, reorderable groups shown as collapsible sections on this page; see [Sites](./sites) for the full walkthrough. The **Create** menu at the top of the page offers New Laravel site…, New WordPress site…, New CodeIgniter site…, New CakePHP site…, New Slim site…, Link existing site, Park folder, and New group…. #### The site details sidebar diff --git a/docs/guide/sites.md b/docs/guide/sites.md index ec3af995..21eef6d3 100644 --- a/docs/guide/sites.md +++ b/docs/guide/sites.md @@ -124,6 +124,22 @@ A final summary of your choices. Click **Create** and the dialog switches to a l When it finishes, the site is on disk, registered, served at `.test`, and ready to use - **Open folder**, **Open in browser**, or **WP Admin** to sign in as the administrator you just created (see below). +## Create a new CodeIgniter, CakePHP or Slim site + +The **Create** menu can also scaffold a project from a framework's official Composer template. Choose **New CodeIgniter site**, **New CakePHP site** or **New Slim site** to open a two-step wizard, **Basics → Review**, that runs `composer create-project` under the PHP version you pick and registers the result as a `.test` site automatically. + +| Framework | Template | PHP | +|---|---|---| +| CodeIgniter 4 | `codeigniter4/appstarter` | 8.1+ | +| CakePHP | `cakephp/app` | 8.1+ | +| Slim 4 | `slim/skeleton` | 7.4+ | + +::: tip Prerequisites +These wizards need a PHP version the framework supports and **Composer**. If either is missing, the wizard offers to install it. An [externally installed](./tooling#external-tools) Composer works too. +::: + +The **Basics** step matches the other wizards: project name, location, PHP version and HTTPS. The web root is detected automatically: `public/` for CodeIgniter and Slim, `webroot/` for CakePHP. A new CodeIgniter site also gets a `.env` created from its `env` template, with `CI_ENVIRONMENT = development` and `app.baseURL` set to the site's URL, so errors show while you work. Yerd keeps an active `app.baseURL` in step when you later toggle HTTPS or change the site's primary domain. A commented-out one is left alone. No database is created. Configure one in the project as usual - see [Services & Databases](./services). + ## WordPress one-click admin login A WordPress site created through the wizard has **one-click admin login** turned on by default: opening **WP Admin** signs you in as the site's administrator instead of showing WordPress's own login screen. Existing or parked WordPress sites can opt in the same way. From 7d96dbeb2b2de4fd077b81ec136a9cdf6e3b407a Mon Sep 17 00:00:00 2001 From: Richard Anderson Date: Sat, 26 Sep 2026 21:13:33 +0100 Subject: [PATCH 2/2] refactor: consolidate wizards. --- .../CreateComposerSiteWizard.spec.ts | 105 +- .../site-create/CreateComposerSiteWizard.vue | 677 ++--------- .../site-create/CreateLaravelWizard.spec.ts | 334 ++++++ .../site-create/CreateLaravelWizard.vue | 1037 +++++----------- .../site-create/CreateSiteWizardShell.vue | 343 ++++++ .../site-create/CreateWordPressWizard.spec.ts | 316 +++++ .../site-create/CreateWordPressWizard.vue | 1041 +++++------------ .../components/site-create/SiteBasicsStep.vue | 107 ++ .../src/composables/useCreateSiteJob.test.ts | 23 + .../src/composables/useCreateSiteJob.ts | 143 +++ .../src/composables/useSiteBasics.test.ts | 31 + .../yerd-gui/src/composables/useSiteBasics.ts | 37 + .../src/composables/useToolPrereqs.test.ts | 119 ++ .../src/composables/useToolPrereqs.ts | 161 +++ bin/yerdd/src/codeigniter_url_sync.rs | 73 +- bin/yerdd/src/create_site/composer_project.rs | 136 +-- bin/yerdd/src/create_site/laravel.rs | 5 +- bin/yerdd/src/create_site/mod.rs | 7 +- bin/yerdd/src/ipc_server.rs | 7 +- bin/yerdd/src/public_url.rs | 91 +- docs/developer/binaries/yerdd.md | 4 +- 21 files changed, 2580 insertions(+), 2217 deletions(-) create mode 100644 apps/yerd-gui/src/components/site-create/CreateLaravelWizard.spec.ts create mode 100644 apps/yerd-gui/src/components/site-create/CreateSiteWizardShell.vue create mode 100644 apps/yerd-gui/src/components/site-create/CreateWordPressWizard.spec.ts create mode 100644 apps/yerd-gui/src/components/site-create/SiteBasicsStep.vue create mode 100644 apps/yerd-gui/src/composables/useCreateSiteJob.test.ts create mode 100644 apps/yerd-gui/src/composables/useCreateSiteJob.ts create mode 100644 apps/yerd-gui/src/composables/useSiteBasics.test.ts create mode 100644 apps/yerd-gui/src/composables/useSiteBasics.ts create mode 100644 apps/yerd-gui/src/composables/useToolPrereqs.test.ts create mode 100644 apps/yerd-gui/src/composables/useToolPrereqs.ts diff --git a/apps/yerd-gui/src/components/site-create/CreateComposerSiteWizard.spec.ts b/apps/yerd-gui/src/components/site-create/CreateComposerSiteWizard.spec.ts index 7b307422..909124c6 100644 --- a/apps/yerd-gui/src/components/site-create/CreateComposerSiteWizard.spec.ts +++ b/apps/yerd-gui/src/components/site-create/CreateComposerSiteWizard.spec.ts @@ -7,6 +7,8 @@ import type { ComposerFramework, ToolStatus } from "@/ipc/types"; const createSite = vi.hoisted(() => vi.fn()); const jobStatus = vi.hoisted(() => vi.fn()); const listTools = vi.hoisted(() => vi.fn()); +const toastSuccess = vi.hoisted(() => vi.fn()); +const toastError = vi.hoisted(() => vi.fn()); vi.mock("@/ipc/client", () => ({ IpcError: class IpcError extends Error {}, @@ -14,7 +16,7 @@ vi.mock("@/ipc/client", () => ({ createSite, installPhpWithProgress: vi.fn(), installToolStreamed: vi.fn(), - jobCancel: vi.fn(), + jobCancel: vi.fn().mockResolvedValue(undefined), jobStatus, listTools, openInBrowser: vi.fn(), @@ -28,7 +30,7 @@ vi.mock("@/composables/useDaemon", () => ({ })); vi.mock("@/composables/useToast", () => ({ - useToast: () => ({ success: vi.fn(), error: vi.fn() }), + useToast: () => ({ success: toastSuccess, error: toastError }), })); function composer(overrides: Partial = {}): ToolStatus { @@ -69,6 +71,8 @@ describe("CreateComposerSiteWizard", () => { jobStatus.mockReset(); listTools.mockReset(); listTools.mockResolvedValue([composer()]); + toastSuccess.mockReset(); + toastError.mockReset(); }); it.each([ @@ -179,6 +183,103 @@ describe("CreateComposerSiteWizard", () => { w.unmount(); }); + it("stops polling when unmounted while a status request is in flight", async () => { + createSite.mockResolvedValue("job-1"); + let resolveStatus: (value: unknown) => void = () => {}; + jobStatus.mockReturnValueOnce( + new Promise((resolve) => { + resolveStatus = resolve; + }), + ); + const w = await mountWizard("slim", ["8.4"]); + + await w.find("#ccs-name").setValue("shop"); + await byText(w, "Next")!.trigger("click"); + await byText(w, "Create site")!.trigger("click"); + await flushPromises(); + expect(jobStatus).toHaveBeenCalledTimes(1); + + w.unmount(); + resolveStatus({ log: [], next_cursor: 0, phase: "Scaffolding", state: "running", error: null }); + await flushPromises(); + await new Promise((resolve) => setTimeout(resolve, 700)); + + expect(jobStatus).toHaveBeenCalledTimes(1); + }); + + it("resumes the progress view when reopened before the daemon returns a job id", async () => { + let resolveCreate: (value: string) => void = () => {}; + createSite.mockReturnValueOnce( + new Promise((resolve) => { + resolveCreate = resolve; + }), + ); + jobStatus.mockResolvedValue({ + log: [], + next_cursor: 0, + phase: "Scaffolding", + state: "succeeded", + error: null, + }); + const w = await mountWizard("slim", ["8.4"]); + + await w.find("#ccs-name").setValue("shop"); + await byText(w, "Next")!.trigger("click"); + await byText(w, "Create site")!.trigger("click"); + await w.setProps({ open: false }); + await w.setProps({ open: true }); + await flushPromises(); + + expect(w.find("#ccs-name").exists()).toBe(false); + resolveCreate("job-1"); + await flushPromises(); + expect(w.emitted("created")).toBeTruthy(); + w.unmount(); + }); + + it("stops the phase spinner when the job fails", async () => { + createSite.mockResolvedValue("job-1"); + jobStatus.mockResolvedValue({ + log: [], + next_cursor: 0, + phase: "Scaffolding", + state: "failed", + error: "composer exploded", + }); + const w = await mountWizard("slim", ["8.4"]); + + await w.find("#ccs-name").setValue("shop"); + await byText(w, "Next")!.trigger("click"); + await byText(w, "Create site")!.trigger("click"); + await flushPromises(); + + expect(w.text()).toContain("composer exploded"); + expect(w.find(".animate-spin").exists()).toBe(false); + w.unmount(); + }); + + it.each([ + ["succeeded", null], + ["failed", "composer exploded"], + ] as const)("toasts a %s create that finishes while the dialog is closed", async (state, error) => { + createSite.mockResolvedValue("job-1"); + jobStatus + .mockResolvedValueOnce({ log: [], next_cursor: 0, phase: "Scaffolding", state: "running", error: null }) + .mockResolvedValue({ log: [], next_cursor: 0, phase: "Done", state, error }); + const w = await mountWizard("slim", ["8.4"]); + + await w.find("#ccs-name").setValue("shop"); + await byText(w, "Next")!.trigger("click"); + await byText(w, "Create site")!.trigger("click"); + await flushPromises(); + await w.setProps({ open: false }); + + const toast = state === "succeeded" ? toastSuccess : toastError; + await vi.waitFor(() => expect(toast).toHaveBeenCalledTimes(1), { timeout: 2000 }); + expect(Boolean(w.emitted("created"))).toBe(state === "succeeded"); + w.unmount(); + }); + it("gates on PHP when every installed version is below the framework minimum", async () => { const w = await mountWizard("codeigniter", ["7.4"]); expect(w.text()).toContain("A few tools are needed first"); diff --git a/apps/yerd-gui/src/components/site-create/CreateComposerSiteWizard.vue b/apps/yerd-gui/src/components/site-create/CreateComposerSiteWizard.vue index 38cc895d..7da0f862 100644 --- a/apps/yerd-gui/src/components/site-create/CreateComposerSiteWizard.vue +++ b/apps/yerd-gui/src/components/site-create/CreateComposerSiteWizard.vue @@ -1,47 +1,12 @@