+
+ ckan-devstaller{" "}
+
+
+
+ Launch CKAN dev instances within minutes.
+
+
+ ckan-devstaller is a command-line tool to automate installing CKAN for
+ development using ckan-compose on a new Ubuntu 22.04 instance.
+
+
+ Provided by{" "}
+
+ datHere
+
+ .
+
+
+
+ Get Started
+
+
+ Source Code
+
+
+
+ } href="/docs/builder" title="Quick start">
+ Get started with ckan-devstaller and install CKAN within minutes
+
+ } href="/docs/builder" title="Builder">
+ Customize your installation with an interactive web GUI
+
+ }
+ href="/docs/reference/installation-architecture"
+ title="Installation architecture"
+ >
+ Learn about where files are installed after running ckan-devstaller
+
+ }
+ href="https://github.com/dathere/ckan-devstaller"
+ title="Source code"
+ >
+ View the source code of ckan-devstaller on GitHub
+
+
+
+
+ One of the primary goals of ckan-devstaller is to ease
+ installation of CKAN for development. Built with Rust for speed
+ and streamlining installation with{" "}
+
+ ckan-compose
+
+ , ckan-devstaller improves installation speeds{" "}
+ from hours/days to just minutes depending on your
+ download speed.
+
+
+
+ Get started
+
+
+
+ ) : null}
+ {active === 1 ? (
+
+
+
+ Customize your installation with the Builder.
+
+
+ Try out the interactive web GUI for customizing your CKAN
+ installation. You can select:
+
+
+
Presets
+
CKAN version
+
Extensions
+
Features
+
+
+ Then you can copy the provided ckan-devstaller command to run your
+ selected configuration.
+
+
+
+ Try out the Builder
+
+
+
+ ) : null}
+ {active === 2 ? (
+
+
+
+ Designed for developers.
+
+
+ We've kept development use cases in mind while developing
+ ckan-devstaller, such as:
+
+ After you've installed CKAN with ckan-devstaller, you can
+ uninstall CKAN with ease. This allows for quickly re-installing
+ CKAN for a different use case.
+
+ {props.codeblockUninstall}
+
+ Learn more about uninstalling
+
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/docs/components/steps.tsx b/docs/components/steps.tsx
new file mode 100644
index 0000000..ce348a2
--- /dev/null
+++ b/docs/components/steps.tsx
@@ -0,0 +1,9 @@
+import type { ReactNode } from 'react';
+
+export function Steps({ children }: { children: ReactNode }) {
+ return
{children}
;
+}
+
+export function Step({ children }: { children: ReactNode }) {
+ return
{children}
;
+}
diff --git a/docs/components/tabs.tsx b/docs/components/tabs.tsx
new file mode 100644
index 0000000..54c41a3
--- /dev/null
+++ b/docs/components/tabs.tsx
@@ -0,0 +1,202 @@
+'use client';
+
+import * as React from 'react';
+import {
+ type ComponentProps,
+ createContext,
+ type ReactNode,
+ useContext,
+ useEffect,
+ useId,
+ useMemo,
+ useState,
+} from 'react';
+import { cn } from '../lib/cn';
+import * as Unstyled from './tabs.unstyled';
+
+type CollectionKey = string | symbol;
+
+export interface TabsProps
+ extends Omit<
+ ComponentProps,
+ 'value' | 'onValueChange'
+ > {
+ /**
+ * Use simple mode instead of advanced usage as documented in https://radix-ui.com/primitives/docs/components/tabs.
+ */
+ items?: string[];
+
+ /**
+ * Shortcut for `defaultValue` when `items` is provided.
+ *
+ * @defaultValue 0
+ */
+ defaultIndex?: number;
+
+ /**
+ * Additional label in tabs list when `items` is provided.
+ */
+ label?: ReactNode;
+}
+
+const TabsContext = createContext<{
+ items?: string[];
+ collection: CollectionKey[];
+} | null>(null);
+
+function useTabContext() {
+ const ctx = useContext(TabsContext);
+ if (!ctx) throw new Error('You must wrap your component in ');
+ return ctx;
+}
+
+export const TabsList = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>((props, ref) => (
+
+));
+TabsList.displayName = 'TabsList';
+
+export const TabsTrigger = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>((props, ref) => (
+
+));
+TabsTrigger.displayName = 'TabsTrigger';
+
+export function Tabs({
+ ref,
+ className,
+ items,
+ label,
+ defaultIndex = 0,
+ defaultValue = items ? escapeValue(items[defaultIndex]) : undefined,
+ ...props
+}: TabsProps) {
+ const [value, setValue] = useState(defaultValue);
+ const collection = useMemo(() => [], []);
+
+ return (
+ {
+ if (items && !items.some((item) => escapeValue(item) === v)) return;
+ setValue(v);
+ }}
+ {...props}
+ >
+ {items && (
+
+ {label && (
+ {label}
+ )}
+ {items.map((item) => (
+
+ {item}
+
+ ))}
+
+ )}
+ ({ items, collection }), [collection, items])}
+ >
+ {props.children}
+
+
+ );
+}
+
+export interface TabProps
+ extends Omit, 'value'> {
+ /**
+ * Value of tab, detect from index if unspecified.
+ */
+ value?: string;
+}
+
+export function Tab({ value, ...props }: TabProps) {
+ const { items } = useTabContext();
+ const resolved =
+ value ??
+ // eslint-disable-next-line react-hooks/rules-of-hooks -- `value` is not supposed to change
+ items?.at(useCollectionIndex());
+ if (!resolved)
+ throw new Error(
+ 'Failed to resolve tab `value`, please pass a `value` prop to the Tab component.',
+ );
+
+ return (
+
+ {props.children}
+
+ );
+}
+
+export function TabsContent({
+ value,
+ className,
+ ...props
+}: ComponentProps) {
+ return (
+ figure:only-child]:-m-4 [&>figure:only-child]:border-none',
+ className,
+ )}
+ {...props}
+ >
+ {props.children}
+
+ );
+}
+
+/**
+ * Inspired by Headless UI.
+ *
+ * Return the index of children, this is made possible by registering the order of render from children using React context.
+ * This is supposed by work with pre-rendering & pure client-side rendering.
+ */
+function useCollectionIndex() {
+ const key = useId();
+ const { collection } = useTabContext();
+
+ useEffect(() => {
+ return () => {
+ const idx = collection.indexOf(key);
+ if (idx !== -1) collection.splice(idx, 1);
+ };
+ }, [key, collection]);
+
+ if (!collection.includes(key)) collection.push(key);
+ return collection.indexOf(key);
+}
+
+/**
+ * only escape whitespaces in values in simple mode
+ */
+function escapeValue(v: string): string {
+ return v.toLowerCase().replace(/\s/, '-');
+}
diff --git a/docs/components/tabs.unstyled.tsx b/docs/components/tabs.unstyled.tsx
new file mode 100644
index 0000000..783095f
--- /dev/null
+++ b/docs/components/tabs.unstyled.tsx
@@ -0,0 +1,163 @@
+'use client';
+
+import {
+ type ComponentProps,
+ createContext,
+ useContext,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import * as Primitive from '@radix-ui/react-tabs';
+import { mergeRefs } from '../lib/merge-refs';
+import { useEffectEvent } from 'fumadocs-core/utils/use-effect-event';
+
+type ChangeListener = (v: string) => void;
+const listeners = new Map();
+
+function addChangeListener(id: string, listener: ChangeListener): void {
+ const list = listeners.get(id) ?? [];
+ list.push(listener);
+ listeners.set(id, list);
+}
+
+function removeChangeListener(id: string, listener: ChangeListener): void {
+ const list = listeners.get(id) ?? [];
+ listeners.set(
+ id,
+ list.filter((item) => item !== listener),
+ );
+}
+
+export interface TabsProps extends ComponentProps {
+ /**
+ * Identifier for Sharing value of tabs
+ */
+ groupId?: string;
+
+ /**
+ * Enable persistent
+ */
+ persist?: boolean;
+
+ /**
+ * If true, updates the URL hash based on the tab's id
+ */
+ updateAnchor?: boolean;
+}
+
+const TabsContext = createContext<{
+ valueToIdMap: Map;
+} | null>(null);
+
+function useTabContext() {
+ const ctx = useContext(TabsContext);
+ if (!ctx) throw new Error('You must wrap your component in ');
+ return ctx;
+}
+
+export const TabsList = Primitive.TabsList;
+
+export const TabsTrigger = Primitive.TabsTrigger;
+
+/**
+ * @internal You better not use it
+ */
+export function Tabs({
+ ref,
+ groupId,
+ persist = false,
+ updateAnchor = false,
+ defaultValue,
+ value: _value,
+ onValueChange: _onValueChange,
+ ...props
+}: TabsProps) {
+ const tabsRef = useRef(null);
+ const [value, setValue] =
+ _value === undefined
+ ? // eslint-disable-next-line react-hooks/rules-of-hooks -- not supposed to change controlled/uncontrolled
+ useState(defaultValue)
+ : [_value, _onValueChange ?? (() => undefined)];
+
+ const onChange = useEffectEvent((v: string) => setValue(v));
+ const valueToIdMap = useMemo(() => new Map(), []);
+
+ useLayoutEffect(() => {
+ if (!groupId) return;
+ const previous = persist
+ ? localStorage.getItem(groupId)
+ : sessionStorage.getItem(groupId);
+
+ if (previous) onChange(previous);
+ addChangeListener(groupId, onChange);
+ return () => {
+ removeChangeListener(groupId, onChange);
+ };
+ }, [groupId, persist]);
+
+ useLayoutEffect(() => {
+ const hash = window.location.hash.slice(1);
+ if (!hash) return;
+
+ for (const [value, id] of valueToIdMap.entries()) {
+ if (id === hash) {
+ onChange(value);
+ tabsRef.current?.scrollIntoView();
+ break;
+ }
+ }
+ }, [valueToIdMap]);
+
+ return (
+ {
+ if (updateAnchor) {
+ const id = valueToIdMap.get(v);
+
+ if (id) {
+ window.history.replaceState(null, '', `#${id}`);
+ }
+ }
+
+ if (groupId) {
+ listeners.get(groupId)?.forEach((item) => {
+ item(v);
+ });
+
+ if (persist) localStorage.setItem(groupId, v);
+ else sessionStorage.setItem(groupId, v);
+ } else {
+ setValue(v);
+ }
+ }}
+ {...props}
+ >
+ ({ valueToIdMap }), [valueToIdMap])}
+ >
+ {props.children}
+
+
+ );
+}
+
+export function TabsContent({
+ value,
+ ...props
+}: ComponentProps) {
+ const { valueToIdMap } = useTabContext();
+
+ if (props.id) {
+ valueToIdMap.set(value, props.id);
+ }
+
+ return (
+
+ {props.children}
+
+ );
+}
diff --git a/docs/components/ui/button.tsx b/docs/components/ui/button.tsx
new file mode 100644
index 0000000..c614777
--- /dev/null
+++ b/docs/components/ui/button.tsx
@@ -0,0 +1,33 @@
+import { cva } from "class-variance-authority";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-fd-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fd-ring disabled:pointer-events-none disabled:opacity-50",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-fd-background bg-gradient-to-b from-fd-primary to-fd-primary/60 text-fd-primary-foreground shadow-inner shadow-fd-background/20 hover:bg-fd-primary/90",
+ outline: "border hover:bg-fd-accent hover:text-fd-accent-foreground",
+ grow: "border bg-gradient-to-t from-fd-primary/10 shadow-inner shadow-fd-primary/10 hover:bg-fd-accent/50 hover:text-fd-accent-foreground",
+ secondary:
+ "border bg-fd-secondary text-fd-secondary-foreground hover:bg-fd-accent hover:text-fd-accent-foreground",
+ ghost: "hover:bg-fd-accent hover:text-fd-accent-foreground",
+ link: "text-fd-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-10 px-4 py-2",
+ icon: "p-1.5",
+ sm: "h-9 px-3",
+ lg: "h-11 px-6",
+ xs: "px-2 py-1.5 text-xs",
+ "icon-xs": "p-1 [&_svg]:size-4"
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+export { buttonVariants };
diff --git a/docs/components/ui/collapsible.tsx b/docs/components/ui/collapsible.tsx
new file mode 100644
index 0000000..dbcf3f0
--- /dev/null
+++ b/docs/components/ui/collapsible.tsx
@@ -0,0 +1,39 @@
+'use client';
+import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
+import { forwardRef, useEffect, useState } from 'react';
+import { cn } from '../../lib/cn';
+
+const Collapsible = CollapsiblePrimitive.Root;
+
+const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
+
+const CollapsibleContent = forwardRef<
+ HTMLDivElement,
+ React.ComponentPropsWithoutRef
+>(({ children, ...props }, ref) => {
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+});
+
+CollapsibleContent.displayName =
+ CollapsiblePrimitive.CollapsibleContent.displayName;
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent };
diff --git a/docs/components/ui/label.tsx b/docs/components/ui/label.tsx
new file mode 100644
index 0000000..5341821
--- /dev/null
+++ b/docs/components/ui/label.tsx
@@ -0,0 +1,26 @@
+"use client"
+
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const labelVariants = cva(
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+)
+
+const Label = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+))
+Label.displayName = LabelPrimitive.Root.displayName
+
+export { Label }
diff --git a/docs/components/ui/sonner.tsx b/docs/components/ui/sonner.tsx
new file mode 100644
index 0000000..331836d
--- /dev/null
+++ b/docs/components/ui/sonner.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import {
+ CircleCheckIcon,
+ InfoIcon,
+ Loader2Icon,
+ OctagonXIcon,
+ TriangleAlertIcon,
+} from "lucide-react";
+import { useTheme } from "next-themes";
+import { Toaster as Sonner, ToasterProps } from "sonner";
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme();
+
+ return (
+ ,
+ info: ,
+ warning: ,
+ error: ,
+ loading: ,
+ }}
+ style={
+ {
+ "--normal-bg": "var(--popover)",
+ "--normal-text": "var(--popover-foreground)",
+ "--normal-border": "var(--border)",
+ "--border-radius": "var(--radius)",
+ } as React.CSSProperties
+ }
+ {...props}
+ />
+ );
+};
+
+export { Toaster };
diff --git a/docs/components/ui/switch.tsx b/docs/components/ui/switch.tsx
new file mode 100644
index 0000000..9511e19
--- /dev/null
+++ b/docs/components/ui/switch.tsx
@@ -0,0 +1,29 @@
+"use client"
+
+import * as React from "react"
+import * as SwitchPrimitives from "@radix-ui/react-switch"
+
+import { cn } from "@/lib/utils"
+
+const Switch = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+))
+Switch.displayName = SwitchPrimitives.Root.displayName
+
+export { Switch }
diff --git a/docs/content/docs/builder.mdx b/docs/content/docs/builder.mdx
new file mode 100644
index 0000000..41701ea
--- /dev/null
+++ b/docs/content/docs/builder.mdx
@@ -0,0 +1,22 @@
+---
+title: Builder
+description: Customize your CKAN installation before running ckan-devstaller.
+icon: Blocks
+---
+
+ckan-devstaller attempts to install a CKAN instance from source along with [ckan-compose](https://github.com/tino097/ckan-compose/tree/ckan-devstaller) and other optional features, intended for development use in a new Ubuntu 22.04 instance.
+
+Make sure `ckan-devstaller` is run in a **new** Ubuntu 22.04 instance. Do NOT run `ckan-devstaller` in an existing instance that is important for your usage.
+
+import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
+
+
+ If you are using Ubuntu 22.04 on VirtualBox, you may need to add your user to the sudoers file before running the ckan-devstaller install script. Open a terminal in your virtual machine (VM), run `su -` and log in as the root user with the password you used to set up the VM, then type:
`sudo adduser sudo`
where `` is your username then restart your VM and run the ckan-devstaller installer script.
+ Currently `ckan-devstaller` supports `x86_64` architecture. `ARM64` support is planned.
+
+
+---
+
+import Builder from "@/components/builder";
+
+
diff --git a/docs/content/docs/changelog/0.3.0.mdx b/docs/content/docs/changelog/0.3.0.mdx
new file mode 100644
index 0000000..b8143a7
--- /dev/null
+++ b/docs/content/docs/changelog/0.3.0.mdx
@@ -0,0 +1,37 @@
+---
+title: Changelog for ckan-devstaller v0.3.0 (2025-10-14)
+---
+
+Since v0.2.1 of ckan-devstaller, there have been many new features and changes now available in v0.3.0.
+
+## New web app: ckan-devstaller.dathere.com
+
+We've released a new web app [ckan-devstaller.dathere.com](https://ckan-devstaller.dathere.com) as the primary documentation site for ckan-devstaller.
+
+## Builder page
+
+There is now an interactive web GUI, the [Builder](/docs/builder), for users to customize their CKAN installation before copying the (now updated) ckan-devstaller command and running it on their terminal. This helps resolve issue [#6](https://github.com/dathere/ckan-devstaller/issues/11).
+
+## Updated Quick Start page
+
+The [Quick Start](/docs) page now includes three options for suggested installation methods:
+
+1. Use the interactive [Builder](/docs/builder).
+2. Install the "CKAN-only" preset with a script which installs the latest stable version of CKAN and ckan-compose (with optional non-interactive script).
+3. Install the "datHere default" preset with a script which installs the latest stable version of CKAN and ckan-compose (with optional non-interactive script) along with the DataStore, ckanext-scheming, and DataPusher+ extensions and also installs the `openssh-server` package.
+
+## Installation architecture page
+
+There is now an [Installation Architecture](/docs/reference/installation-architecture) page in the Reference section of the web app that provides a visual representation of where `ckan-devstaller` installs relevant files/folders.
+
+## Uninstall CKAN page
+
+There is now an [Uninstall CKAN](/docs/tutorials/uninstall-ckan) page in the Tutorials section of the web app that helps users understand how to uninstall their newly installed CKAN installation. This includes the option to either use the new `ckan-devstaller uninstall` subcommand or run the script directly.
+
+## README update
+
+The README on the [ckan-devstaller GitHub repository](https://github.com/dathere/ckan-devstaller) has been updated to have a more user-friendly focus for users and developers that may be new to CKAN thanks to the suggestions by [@drw](https://github.com/drw) in issues [#10](https://github.com/dathere/ckan-devstaller/issues/10) and [#11](https://github.com/dathere/ckan-devstaller/issues/11).
+
+## Changelog section
+
+We've added a Changelog section to the web app to denote new changes to `ckan-devstaller` for each release.
diff --git a/docs/content/docs/changelog/0.3.1.mdx b/docs/content/docs/changelog/0.3.1.mdx
new file mode 100644
index 0000000..536bde0
--- /dev/null
+++ b/docs/content/docs/changelog/0.3.1.mdx
@@ -0,0 +1,37 @@
+---
+title: Changelog for ckan-devstaller v0.3.1 (2025-10-30)
+---
+
+## Updated CKAN default versions to latest stable versions
+
+We have updated ckan-devstaller to suggest the recent releases of CKAN [2.11.4](https://docs.ckan.org/en/2.11/changelog.html#v-2-11-4-2025-10-29) and [2.10.9](https://docs.ckan.org/en/2.10/changelog.html#v-2-10-9-2025-10-29).
+
+## Added the "Developing with WSL" page in the Reference section
+
+Developers using Windows may benefit from the new [Developing with WSL](/docs/reference/developing-with-wsl) page in the Reference section to try their builds of ckan-devstaller and verify things work on a new install of Ubuntu 22.04.
+
+Alternatively if not trying to reset an entire WSL environment, developers can look into [`ckan-devstaller uninstall`](/docs/tutorials/uninstall-ckan).
+
+## Added install script switch to Builder page
+
+There is now a switch on the Builder page that is enabled by default for including the installation script for ckan-devstaller before running it. This is necessary for users that don't have ckan-devstaller installed yet and are using the Builder over the Quick Start scripts.
+
+## Default start with the Builder page
+
+We've changed "Get Started" links (and similar links) to the Builder page instead of the Quick Start page to users can quickly access the Builder again and get started using ckan-devstaller.
+
+## Update ckan-compose links to use ckan-devstaller branch
+
+We've updated ckan-compose links to use [the ckan-devstaller branch](https://github.com/tino097/ckan-compose/tree/ckan-devstaller).
+
+## Update VirtualBox notice with better formatting
+
+To ensure users using VirtualBox see the full command for adding a user to the sudoers file, we've improved the formatting of the note.
+
+## GitHub Action to verify CKAN install runs
+
+We've added a GitHub Action to run on push to the main branch to verify that a CKAN-only install runs. This doesn't include verbose testing but rather ensuring that the ckan-devstaller finishes without errors.
+
+## New --skip-run flag
+
+We've added a `--skip-run` flag to skip running CKAN at the end of installation.
diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx
new file mode 100644
index 0000000..ffa0d8d
--- /dev/null
+++ b/docs/content/docs/index.mdx
@@ -0,0 +1,120 @@
+---
+title: Quick Start
+description: Get started with ckan-devstaller and install CKAN within minutes.
+icon: Zap
+---
+
+ckan-devstaller attempts to install a CKAN instance from source along with [ckan-compose](https://github.com/tino097/ckan-compose/tree/ckan-devstaller) and other optional features, intended for development use in a new Ubuntu 22.04 instance.
+
+Make sure `ckan-devstaller` is run in a **new** Ubuntu 22.04 instance. Do NOT run `ckan-devstaller` in an existing instance that is important for your usage.
+
+import { Accordion, Accordions } from 'fumadocs-ui/components/accordion';
+
+
+ If you are using Ubuntu 22.04 on VirtualBox, you may need to add your user to the sudoers file before running the ckan-devstaller install script. Open a terminal in your virtual machine (VM), run `su -` and log in as the root user with the password you used to set up the VM, then type:
`sudo adduser sudo`
where `` is your username then restart your VM and run the ckan-devstaller installer script.
+ Currently `ckan-devstaller` supports `x86_64` architecture. `ARM64` support is planned.
+
+
+---
+
+## Install CKAN using ckan-devstaller
+
+You have several options to choose from for installation. Here are a few you may choose one from:
+
+import { Step, Steps } from 'fumadocs-ui/components/steps';
+
+
+
+
+### Customize your CKAN installation with the Builder (Recommended)
+
+}
+ href="/docs/builder"
+ title="Builder"
+ >
+ Click here to customize your CKAN installation with an interactive web GUI
+
+
+
+
+
+
+### Install the "CKAN-only" preset
+
+By running the following script, ckan-devstaller will be downloaded and the default configuration for installing CKAN with ckan-compose will be selected. You can then customize your configuration interactively in your terminal after running this script.
+
+```bash
+wget -O - https://github.com/dathere/ckan-devstaller/releases/download/0.3.1/install.bash | bash
+```
+
+If you'd rather skip the interactivity and go straight to installation, then run the following script instead:
+
+```bash
+wget -O - https://github.com/dathere/ckan-devstaller/releases/download/0.3.1/install.bash | bash -s skip-interactive
+```
+
+
+
+
+
+### Install the "datHere Default" preset
+
+The following script will download ckan-devstaller and select the following configuration:
+
+- CKAN latest stable version
+- [ckan-compose](https://github.com/tino097/ckan-compose/tree/ckan-devstaller)
+- [DataStore extension](https://docs.ckan.org/en/2.11/maintaining/datastore.html)
+- [ckanext-scheming extension](https://github.com/ckan/ckanext-scheming)
+- [DataPusher+ extension](https://github.com/dathere/datapusher-plus)
+- Install the `openssh-server` package for allowing SSH capability
+- [DRUF mode](https://github.com/dathere/datapusher-plus?tab=readme-ov-file#druf-dataset-resource-upload-first-workflow) for DataPusher+ is available but disabled by default.
+
+You can then customize your configuration interactively in your terminal after running this script.
+
+```bash
+wget -O - https://github.com/dathere/ckan-devstaller/releases/download/0.3.1/install.bash | bash -s dathere-default
+```
+
+If you'd rather skip the interactivity and go straight to installation, then run the following script instead:
+
+```bash
+wget -O - https://github.com/dathere/ckan-devstaller/releases/download/0.3.1/install.bash | bash -s dathere-default skip-interactive
+```
+
+
+
+
+## Learn more
+
+import { BlocksIcon, HomeIcon, GitMergeIcon, Trash2Icon } from 'lucide-react';
+
+
+ }
+ href="/docs/builder"
+ title="Builder"
+ >
+ Customize your installation with an interactive web GUI
+
+ }
+ href="/docs/reference/installation-architecture"
+ title="Installation architecture"
+ >
+ Learn about where files are installed after running ckan-devstaller
+
+ }
+ href="/docs/tutorials/uninstall-ckan"
+ title="Uninstall CKAN"
+ >
+ Learn how to uninstall CKAN after running ckan-devstaller
+
+ }
+ href="https://github.com/dathere/ckan-devstaller"
+ title="Source code">
+ View the source code of ckan-devstaller on GitHub
+
+
diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json
new file mode 100644
index 0000000..608d5ba
--- /dev/null
+++ b/docs/content/docs/meta.json
@@ -0,0 +1,13 @@
+{
+ "pages": [
+ "---Introduction---",
+ "index",
+ "builder",
+ "---Further reading---",
+ "tutorials",
+ "reference",
+ "changelog",
+ "--- ---",
+ "[Privacy Policy](https://dathere.com/privacy-policy/)"
+ ]
+}
\ No newline at end of file
diff --git a/docs/content/docs/reference/developing-with-wsl.mdx b/docs/content/docs/reference/developing-with-wsl.mdx
new file mode 100644
index 0000000..c413785
--- /dev/null
+++ b/docs/content/docs/reference/developing-with-wsl.mdx
@@ -0,0 +1,131 @@
+---
+title: Developing with WSL
+description: Tips on how to develop ckan-devstaller on a Windows machine by leveraging Windows Subsystem for Linux.
+---
+
+When developing ckan-devstaller on Windows, using Windows Subsystem for Linux (WSL) can be advantageous to demo an Ubuntu 22.04 environment without having to set up a virtual machine.
+
+import { Step, Steps } from 'fumadocs-ui/components/steps';
+import { File, Folder, Files } from 'fumadocs-ui/components/files';
+
+
+
+
+### Install the Ubuntu-22.04 distribution
+
+You'll need to have the Ubuntu-22.04 distribution installed by running the following command:
+
+```bash
+wsl --install Ubuntu-22.04 --version 2
+```
+
+
+
+
+
+### Export a base image
+
+
+
+Here's the expected set up where we create a WSL folder:
+
+```files
+/c/Users/rzmk/WSL
+├── images
+| ├── ubuntu-22-04-snapshot.tar
+├── instances
+| ├── cdr.vdhx
+```
+
+First we'll generate the `images/ubuntu-22-04-snapshot.tar` file:
+
+```bash
+wsl --export Ubuntu-22.04 ./images/ubuntu-22-04-snapshot.tar
+```
+
+
+
+### Generate a VDHX file for our new instance
+
+Next we'll generate the `instances/cdr.vdhx` file, so we can run the following:
+
+```bash
+wsl --import cdr ./instances ./images/ubuntu-22-04-snapshot.tar
+```
+
+
+
+
+
+### Access your new instance as root
+
+Now try to access your new Ubuntu 22.04 instance `cdr` as the `root` user by running:
+
+```bash
+wsl -d cdr
+```
+
+
+
+
+
+### Set yourself as the admin user on the instance and log in as the admin user
+
+Once logged in as `root`, you'll want to edit the `/etc/wsl.conf` file and modify it so you can login as an admin user instead of `root`. We can use the `nano` editor to modify the file:
+
+```bash
+nano /etc/wsl.conf
+```
+
+Modify the file by adding a `[user]` section with the value `default=rzmk` (where `rzmk` is your username). The file should look similar to this:
+
+```ini
+[boot]
+systemd=true
+
+[user]
+default=rzmk
+```
+
+Now leave your instance:
+
+```bash
+exit
+```
+
+Then terminate the instance:
+
+```bash
+wsl --terminate cdr
+```
+
+Run the instance again and you should be logged in as your admin user by default now:
+
+```bash
+wsl -d cdr
+```
+
+
+
+
+
+### Install ckan-devstaller
+
+Great, now you can go to the home directory and run one of the [quick start](/docs) scripts:
+
+```bash
+cd ~/
+```
+
+
+
+
+## Removing your instance
+
+When you want to remove your instance (e.g. so that you can start a brand new instance) then run the following to unregister it:
+
+```bash
+wsl --unregister cdr
+```
+
+Then you can follow the steps again from step 3 to generate and try out a new instance.
diff --git a/docs/content/docs/reference/installation-architecture.mdx b/docs/content/docs/reference/installation-architecture.mdx
new file mode 100644
index 0000000..16369f0
--- /dev/null
+++ b/docs/content/docs/reference/installation-architecture.mdx
@@ -0,0 +1,76 @@
+---
+title: Installation architecture
+description: View a brief overview of what the installation from ckan-devstaller looks like
+---
+
+import { File, Folder, Files } from 'fumadocs-ui/components/files';
+
+## CKAN and extensions
+
+The CKAN repository selected from ckan-devstaller is installed to `/usr/lib/ckan/default/src/ckan`. Extensions are also installed as sibling folders. For example if `ckanext-scheming` is also installed:
+
+```files
+/usr/lib/ckan/default/src
+├── ckan
+│ ├── ...
+├── ckanext-scheming
+│ ├── ...
+```
+
+The configuration file for CKAN is installed at `/etc/ckan/default/ckan.ini`:
+
+```files
+/etc/ckan/default
+├── ckan.ini
+├── who.ini
+```
+
+## ckan-compose
+
+We install certain first-time install files and [`ckan-compose`](https://github.com/tino097/ckan-compose/tree/ckan-devstaller) as a directory in the user's home (`~`) directory. For example for the user `adam`:
+
+```files
+/home/adam
+├── ahoy
+├── dpp_default_config.ini
+├── get-docker.sh
+├── permissions.sql
+├── ckan-compose
+│ ├── ...
+```
+
+After running ckan-devstaller you may also see many files starting with `qsv` and `README`. There are various files you can remove after running ckan-devstaller including:
+
+- `dpp_default_config.ini`
+- `get-docker.sh`
+- `permissions.sql`
+- `README`
+- The various `qsv` files
+
+Here's a script you can run for cleanup after running ckan-devstaller:
+
+```bash
+cd ~/
+rm -rf dpp_default_config.ini get-docker.sh permissions.sql README qsv*
+```
+
+## DataPusher+
+
+We install a compatible version of qsv with the DataPusher+ variant named `qsvdp` and move it to `/usr/local/bin`:
+
+```files
+/usr/local/bin
+├── qsvdp
+```
+
+The ckanext-scheming and DataPusher+ extensions are installed in the same location as other CKAN extensions:
+
+```files
+/usr/lib/ckan/default/src
+├── ckan
+| ├── ...
+├── ckanext-scheming
+| ├── ...
+├── datapusher_plus
+| ├── ...
+```
diff --git a/docs/content/docs/reference/meta.json b/docs/content/docs/reference/meta.json
new file mode 100644
index 0000000..0d08c26
--- /dev/null
+++ b/docs/content/docs/reference/meta.json
@@ -0,0 +1,6 @@
+{
+ "pages": [
+ "installation-architecture",
+ "developing-with-wsl"
+ ]
+}
\ No newline at end of file
diff --git a/docs/content/docs/tutorials/uninstall-ckan.mdx b/docs/content/docs/tutorials/uninstall-ckan.mdx
new file mode 100644
index 0000000..fafe9fd
--- /dev/null
+++ b/docs/content/docs/tutorials/uninstall-ckan.mdx
@@ -0,0 +1,22 @@
+---
+title: Uninstall CKAN
+description: How to uninstall CKAN after having installed with ckan-devstaller
+---
+
+You may want to uninstall CKAN and related files after having ran ckan-devstaller. This can be useful if you want to re-run ckan-devstaller with a different configuration or are developing ckan-devstaller.
+
+The uninstallation process can be done by running:
+
+```bash
+./ckan-devstaller uninstall
+```
+
+The following script will be ran to uninstall CKAN and files related to ckan-devstaller:
+
+```bash
+sudo rm -rf /usr/lib/ckan
+sudo rm -rf /etc/ckan
+cd ~/
+rm -rf qsv*
+rm -rf README ckan-compose ahoy dpp_default_config.ini get-docker.sh permissions.sql
+```
diff --git a/docs/content/docs/what-is-ckan-devstaller.mdx b/docs/content/docs/what-is-ckan-devstaller.mdx
new file mode 100644
index 0000000..8cb135b
--- /dev/null
+++ b/docs/content/docs/what-is-ckan-devstaller.mdx
@@ -0,0 +1,19 @@
+---
+title: What is ckan-devstaller?
+description: Learn about why ckan-devstaller was built and how it may help you.
+icon: CircleQuestionMark
+---
+
+TODO: Improve this page.
+
+## Introduction
+
+Description
+
+Cards
+
+## Who is ckan-devstaller for
+
+## How can I use ckan-devstaller
+
+## Learn more
diff --git a/docs/lib/cn.ts b/docs/lib/cn.ts
new file mode 100644
index 0000000..ba66fd2
--- /dev/null
+++ b/docs/lib/cn.ts
@@ -0,0 +1 @@
+export { twMerge as cn } from 'tailwind-merge';
diff --git a/docs/lib/inter.ttf b/docs/lib/inter.ttf
new file mode 100644
index 0000000..e31b51e
Binary files /dev/null and b/docs/lib/inter.ttf differ
diff --git a/docs/lib/layout.shared.tsx b/docs/lib/layout.shared.tsx
new file mode 100644
index 0000000..ece1668
--- /dev/null
+++ b/docs/lib/layout.shared.tsx
@@ -0,0 +1,25 @@
+import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
+import { SailboatIcon } from "lucide-react";
+
+/**
+ * Shared layout configurations
+ *
+ * you can customise layouts individually from:
+ * Home Layout: app/(home)/layout.tsx
+ * Docs Layout: app/docs/layout.tsx
+ */
+export function baseOptions(): BaseLayoutProps {
+ return {
+ nav: {
+ title: (
+ <>
+
+ ckan-devstaller
+ >
+ ),
+ },
+ // see https://fumadocs.dev/docs/ui/navigation/links
+ links: [],
+ githubUrl: "https://github.com/dathere/ckan-devstaller",
+ };
+}
diff --git a/docs/lib/merge-refs.ts b/docs/lib/merge-refs.ts
new file mode 100644
index 0000000..7d05f74
--- /dev/null
+++ b/docs/lib/merge-refs.ts
@@ -0,0 +1,15 @@
+import type * as React from 'react';
+
+export function mergeRefs(
+ ...refs: (React.Ref | undefined)[]
+): React.RefCallback {
+ return (value) => {
+ refs.forEach((ref) => {
+ if (typeof ref === 'function') {
+ ref(value);
+ } else if (ref) {
+ ref.current = value;
+ }
+ });
+ };
+}
diff --git a/docs/lib/source.ts b/docs/lib/source.ts
new file mode 100644
index 0000000..1068bc6
--- /dev/null
+++ b/docs/lib/source.ts
@@ -0,0 +1,31 @@
+import { type InferPageType, loader } from "fumadocs-core/source";
+import { icons } from "lucide-react";
+import { createElement } from "react";
+import { docs } from "@/.source";
+
+// See https://fumadocs.vercel.app/docs/headless/source-api for more info
+export const source = loader({
+ baseUrl: "/docs",
+ source: docs.toFumadocsSource(),
+ icon(icon) {
+ if (!icon) return;
+ if (icon in icons) return createElement(icons[icon as keyof typeof icons]);
+ },
+});
+
+export function getPageImage(page: InferPageType) {
+ const segments = [...page.slugs, "image.png"];
+
+ return {
+ segments,
+ url: `/og/docs/${segments.join("/")}`,
+ };
+}
+
+export async function getLLMText(page: InferPageType) {
+ const processed = await page.data.getText("processed");
+
+ return `# ${page.data.title} (${page.url})
+
+${processed}`;
+}
diff --git a/docs/lib/utils.ts b/docs/lib/utils.ts
new file mode 100644
index 0000000..bd0c391
--- /dev/null
+++ b/docs/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/docs/mdx-components.tsx b/docs/mdx-components.tsx
new file mode 100644
index 0000000..1015181
--- /dev/null
+++ b/docs/mdx-components.tsx
@@ -0,0 +1,11 @@
+import { ImageZoom } from "fumadocs-ui/components/image-zoom";
+import defaultComponents from "fumadocs-ui/mdx";
+import type { MDXComponents } from "mdx/types";
+
+export function getMDXComponents(components?: MDXComponents): MDXComponents {
+ return {
+ ...defaultComponents,
+ img: (props) => ,
+ ...components,
+ };
+}
diff --git a/docs/next.config.mjs b/docs/next.config.mjs
new file mode 100644
index 0000000..0e6c632
--- /dev/null
+++ b/docs/next.config.mjs
@@ -0,0 +1,13 @@
+import { createMDX } from 'fumadocs-mdx/next';
+
+const withMDX = createMDX();
+
+/** @type {import('next').NextConfig} */
+const config = {
+ reactStrictMode: true,
+ images: {
+ unoptimized: true
+ }
+};
+
+export default withMDX(config);
diff --git a/docs/package.json b/docs/package.json
new file mode 100644
index 0000000..52dd9d0
--- /dev/null
+++ b/docs/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "ckan-devstaller-docs",
+ "version": "0.0.0",
+ "private": true,
+ "scripts": {
+ "build": "next build",
+ "dev": "next dev --turbo",
+ "start": "next start",
+ "postinstall": "fumadocs-mdx",
+ "lint": "biome check",
+ "format": "biome format --write"
+ },
+ "dependencies": {
+ "@radix-ui/react-accordion": "^1.2.12",
+ "@radix-ui/react-collapsible": "^1.1.12",
+ "@radix-ui/react-label": "^2.1.7",
+ "@radix-ui/react-switch": "^1.2.6",
+ "@radix-ui/react-tabs": "^1.1.13",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "fumadocs-core": "15.8.1",
+ "fumadocs-mdx": "12.0.1",
+ "fumadocs-ui": "15.8.1",
+ "lucide-react": "^0.545.0",
+ "next": "15.5.4",
+ "next-themes": "^0.4.6",
+ "react": "^19.1.1",
+ "react-dom": "^19.1.1",
+ "sonner": "^2.0.7",
+ "tailwind-merge": "^3.3.1"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "2.2.6",
+ "@tailwindcss/postcss": "^4.1.13",
+ "@types/mdx": "^2.0.13",
+ "@types/node": "24.5.2",
+ "@types/react": "^19.1.14",
+ "@types/react-dom": "^19.1.9",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^4.1.13",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "^5.9.2"
+ }
+}
\ No newline at end of file
diff --git a/docs/postcss.config.mjs b/docs/postcss.config.mjs
new file mode 100644
index 0000000..a34a3d5
--- /dev/null
+++ b/docs/postcss.config.mjs
@@ -0,0 +1,5 @@
+export default {
+ plugins: {
+ '@tailwindcss/postcss': {},
+ },
+};
diff --git a/docs/public/docs-home-example.png b/docs/public/docs-home-example.png
new file mode 100644
index 0000000..777f04a
Binary files /dev/null and b/docs/public/docs-home-example.png differ
diff --git a/docs/source.config.ts b/docs/source.config.ts
new file mode 100644
index 0000000..e49b84a
--- /dev/null
+++ b/docs/source.config.ts
@@ -0,0 +1,27 @@
+import { remarkMdxFiles } from 'fumadocs-core/mdx-plugins';
+import {
+ defineConfig,
+ defineDocs,
+ frontmatterSchema,
+ metaSchema,
+} from 'fumadocs-mdx/config';
+
+// You can customise Zod schemas for frontmatter and `meta.json` here
+// see https://fumadocs.dev/docs/mdx/collections
+export const docs = defineDocs({
+ docs: {
+ schema: frontmatterSchema,
+ postprocess: {
+ includeProcessedMarkdown: true,
+ },
+ },
+ meta: {
+ schema: metaSchema,
+ },
+});
+
+export default defineConfig({
+ mdxOptions: {
+ remarkPlugins: [remarkMdxFiles],
+ },
+});
diff --git a/docs/tsconfig.json b/docs/tsconfig.json
new file mode 100644
index 0000000..4fd260f
--- /dev/null
+++ b/docs/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "compilerOptions": {
+ "baseUrl": ".",
+ "target": "ESNext",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "paths": {
+ "@/.source": ["./.source/index.ts"],
+ "@/*": ["./*"]
+ },
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ]
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/install.bash b/install.bash
index 11e582f..b8b0001 100644
--- a/install.bash
+++ b/install.bash
@@ -11,10 +11,25 @@ sudo apt install curl -y
cd ~/
# Download the ckan-devstaller binary file
-curl -LO https://github.com/dathere/ckan-devstaller/releases/download/0.1.0/ckan-devstaller
+curl -LO https://github.com/dathere/ckan-devstaller/releases/download/0.3.1/ckan-devstaller
# Add execute permission to ckan-devstaller binary file
sudo chmod +x ./ckan-devstaller
-# Run the ckan-devstaller binary file
-./ckan-devstaller
+# Run the ckan-devstaller binary file with the specified preset and (non-)interactive mode
+preset=$1
+skip_interactive=$2
+
+if [ $preset == "dathere-default" ]; then
+ if [ $skip_interactive == "skip-interactive" ]; then
+ ./ckan-devstaller --ckan-version 2.11.4 --extensions ckanext-scheming DataStore DataPusher+ --features enable-ssh --skip-interactive
+ else
+ ./ckan-devstaller --ckan-version 2.11.4 --extensions ckanext-scheming DataStore DataPusher+ --features enable-ssh
+ fi
+else
+ if [ $preset == "skip-interactive" ]; then
+ ./ckan-devstaller --skip-interactive
+ else
+ ./ckan-devstaller
+ fi
+fi
diff --git a/src/main.rs b/src/main.rs
index c7c5daa..f304792 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,159 +1,224 @@
+mod questions;
+mod steps;
+mod styles;
+
+use crate::{
+ questions::{question_ckan_version, question_ssh, question_sysadmin},
+ steps::{
+ step_install_ahoy, step_install_and_run_ckan_compose,
+ step_install_ckanext_scheming_extension, step_install_curl,
+ step_install_datapusher_plus_extension, step_install_datastore_extension,
+ step_install_docker, step_install_openssh, step_package_updates,
+ },
+ styles::{important_text, step_text, success_text},
+};
use anyhow::Result;
-use clap::Parser;
+use clap::{Parser, Subcommand};
+use human_panic::{metadata, setup_panic};
use inquire::Confirm;
-use owo_colors::{OwoColorize, Stream::Stdout, Style};
-use serde_json::json;
use std::{path::PathBuf, str::FromStr};
use xshell::cmd;
use xshell_venv::{Shell, VirtualEnv};
-/// ckan-devstaller CLI
-#[derive(Parser, Debug)]
+/// CLI to help install a CKAN instance for development within minutes. Learn more at: https://ckan-devstaller.dathere.com
+#[derive(Parser)]
#[command(version, about, long_about = None)]
struct Args {
- /// Skip interactive steps and install CKAN with default features
+ /// Skip interactive steps
#[arg(short, long)]
- default: bool,
+ skip_interactive: bool,
+ /// Skip running CKAN at the end of installation
+ #[arg(short, long)]
+ skip_run: bool,
+ #[arg(short, long)]
+ /// CKAN version to install defined by semantic versioning from official releases from https://github.com/ckan/ckan
+ ckan_version: Option,
+ /// List of CKAN extensions to install, separated by spaces
+ #[arg(short, long, value_parser, num_args = 1.., value_delimiter = ' ')]
+ extensions: Option>,
+ /// List of custom features, separated by spaces
+ #[arg(short, long, value_parser, num_args = 1.., value_delimiter = ' ')]
+ features: Option>,
+ #[command(subcommand)]
+ command: Option,
+}
+
+#[derive(Subcommand)]
+enum Commands {
+ /// Attempt to uninstall CKAN and related ckan-devstaller installation files
+ Uninstall {},
+}
+
+#[derive(Clone)]
+struct Sysadmin {
+ username: String,
+ password: String,
+ email: String,
+}
+
+struct Config {
+ ssh: bool,
+ ckan_version: String,
+ sysadmin: Sysadmin,
+ extension_datastore: bool,
+ extension_ckanext_scheming: bool,
+ extension_datapusher_plus: bool,
+ druf_mode: bool,
}
fn main() -> Result<()> {
+ setup_panic!(metadata!()
+ .homepage("https://dathere.com")
+ .support("- Create a support ticket at https://support.dathere.com or report an issue at https://github.com/dathere/ckan-devstaller"));
+
+ // Set up default config
let args = Args::parse();
+ let sh = Shell::new()?;
+ let username = cmd!(sh, "whoami").read()?;
- // Color styles
- let highlight_style = Style::new().on_blue().white();
- let important_style = Style::new().on_bright_red().white();
- let step_style = Style::new().on_magenta().white();
- let success_style = Style::new().on_green().white();
+ if matches!(&args.command, Some(Commands::Uninstall {})) {
+ let uninstall_confirmation = Confirm::new(
+ "Are you sure you want to uninstall CKAN and related files from ckan-devstaller?",
+ )
+ .with_help_message(
+ r#"The following commands are ran when attempting the uninstall:
+sudo rm -rf /usr/lib/ckan
+sudo rm -rf /etc/ckan
+cd ~/
+rm -rf qsv*
+rm -rf README ckan-compose ahoy dpp_default_config.ini get-docker.sh permissions.sql"#,
+ )
+ .prompt()?;
+ if uninstall_confirmation {
+ cmd!(sh, "sudo rm -rf /usr/lib/ckan").run()?;
+ cmd!(sh, "sudo rm -rf /etc/ckan").run()?;
+ sh.change_dir(format!("/home/{username}"));
+ cmd!(sh, "rm -rf qsv*").run()?;
+ cmd!(sh, "rm -rf README ckan-compose ahoy dpp_default_config.ini get-docker.sh permissions.sql").run()?;
+ } else {
+ println!("Cancelling command.");
+ }
+ return Ok(());
+ }
- println!("Welcome to the ckan-devstaller!");
- println!(
- "ckan-devstaller is provided by datHere - {}\n",
- "https://datHere.com".if_supports_color(Stdout, |t| t.style(highlight_style)),
- );
- println!(
- "This installer should assist in setting up {} from a source installation along with ckan-compose (https://github.com/tino097/ckan-compose). If you have any issues, please report them at https://github.com/dathere/ckan-devstaller/issues.",
- "CKAN 2.11.3".if_supports_color(Stdout, |t| t.style(highlight_style))
- );
- println!(
- "{}",
- "This installer is only intended for a brand new installation of Ubuntu 22.04."
- .if_supports_color(Stdout, |t| t.style(important_style))
- );
-
- let ans = if args.default {
- true
- } else {
- Confirm::new("Would you like to begin the installation?")
- .with_default(false)
- .prompt()?
+ let default_sysadmin = Sysadmin {
+ username: username.clone(),
+ password: "password".to_string(),
+ email: format!("{username}@localhost"),
+ };
+ let config = Config {
+ ssh: args
+ .features
+ .is_some_and(|features| features.contains(&"enable-ssh".to_string())),
+ ckan_version: if args.ckan_version.is_some() {
+ args.ckan_version.unwrap()
+ } else {
+ "2.11.4".to_string()
+ },
+ sysadmin: default_sysadmin.clone(),
+ extension_datastore: args
+ .extensions
+ .clone()
+ .is_some_and(|extensions| extensions.contains(&"DataStore".to_string())),
+ extension_ckanext_scheming: args
+ .extensions
+ .clone()
+ .is_some_and(|extensions| extensions.contains(&"ckanext-scheming".to_string())),
+ extension_datapusher_plus: args
+ .extensions
+ .is_some_and(|extensions| extensions.contains(&"DataPusher+".to_string())),
+ druf_mode: false,
};
- if ans {
- let sh = Shell::new()?;
- let username = cmd!(sh, "whoami").read()?;
- println!(
- "\n{} Running {} and {}...",
- "1.".if_supports_color(Stdout, |t| t.style(step_style)),
- "sudo apt update -y".if_supports_color(Stdout, |t| t.style(highlight_style)),
- "sudo apt upgrade -y".if_supports_color(Stdout, |t| t.style(highlight_style))
- );
- println!(
- "{}",
- "You may need to provide your sudo password."
- .if_supports_color(Stdout, |t| t.style(important_style))
- );
- cmd!(sh, "sudo apt update -y").run()?;
- // Ignoring xrdp error with .ignore_status() for now
- cmd!(sh, "sudo apt upgrade -y").ignore_status().run()?;
- println!(
- "{}",
- "✅ 1. Successfully ran update and upgrade commands."
- .if_supports_color(Stdout, |t| t.style(success_style))
- );
+ steps::step_intro();
- println!(
- "\n{} Installing curl and enabling SSH...",
- "2.".if_supports_color(Stdout, |t| t.style(step_style)),
- );
- cmd!(sh, "sudo apt install curl openssh-server -y").run()?;
- println!(
- "{}",
- "✅ 2. Successfully installed curl and enabled SSH."
- .if_supports_color(Stdout, |t| t.style(success_style))
- );
+ let mut default_config_text =
+ String::from("The current configuration for ckan-devstaller does the following:");
+ if config.ssh {
+ default_config_text.push_str("\n- Install openssh-server to enable SSH access");
+ }
+ default_config_text.push_str("\n- Install ckan-compose (https://github.com/tino097/ckan-compose/tree/ckan-devstaller) which sets up the CKAN backend (PostgreSQL, SOLR, Redis)");
+ default_config_text.push_str(format!("\n- Install CKAN v{}", config.ckan_version).as_str());
+ if config.extension_datastore {
+ default_config_text.push_str("\n- Install the DataStore extension");
+ }
+ if config.extension_ckanext_scheming {
+ default_config_text.push_str("\n- Install the ckanext-scheming extension");
+ }
+ if config.extension_datapusher_plus {
+ default_config_text.push_str("\n- Install the DataPusher+ extension");
+ default_config_text.push_str("\n- Disable DRUF mode for DataPusher+");
+ }
+ println!("{default_config_text}");
+ let answer_customize = if args.skip_interactive {
+ false
+ } else {
+ Confirm::new("Would you like to customize the configuration for your CKAN installation?")
+ .prompt()?
+ };
+ let config = if answer_customize {
+ let answer_ssh = question_ssh()?;
+ let answer_ckan_version = question_ckan_version()?;
+ let answer_sysadmin = question_sysadmin(username.clone())?;
+ // let answer_extension_datastore = Confirm::new("Would you like to install the DataStore extension?")
+ // .with_default(true)
+ // .prompt()?;
+ // let answer_extension_ckanext_scheming = Confirm::new("Would you like to install the ckanext-scheming extension?")
+ // .with_default(true)
+ // .prompt()?;
+ let answer_extension_datapusher_plus =
+ Confirm::new("Would you like to install the DataPusher+ extension?")
+ .with_default(true)
+ .prompt()?;
+ let answer_druf_mode = if answer_extension_datapusher_plus {
+ Confirm::new("Would you like to enable DRUF mode for DataPusher+?")
+ .with_default(false)
+ .prompt()?
+ } else {
+ false
+ };
+ Config {
+ ssh: answer_ssh,
+ ckan_version: answer_ckan_version,
+ sysadmin: answer_sysadmin,
+ extension_datastore: true,
+ extension_ckanext_scheming: true,
+ extension_datapusher_plus: answer_extension_datapusher_plus,
+ druf_mode: answer_druf_mode,
+ }
+ } else {
+ config
+ };
- let dpkg_l_output = cmd!(sh, "dpkg -l").read()?;
- let has_docker = cmd!(sh, "grep docker")
- .stdin(dpkg_l_output.clone())
- .ignore_status()
- .output()?
- .status
- .success();
- if !has_docker {
- println!(
- "{} Installing Docker...",
- "3.".if_supports_color(Stdout, |t| t.style(step_style)),
- );
- cmd!(
- sh,
- "curl -fsSL https://get.docker.com -o /home/{username}/get-docker.sh"
- )
- .run()?;
- cmd!(sh, "sudo sh /home/{username}/get-docker.sh").run()?;
- println!(
- "{}",
- "✅ 3. Successfully installed Docker."
- .if_supports_color(Stdout, |t| t.style(success_style))
- );
+ let begin_installation = if args.skip_interactive {
+ true
+ } else {
+ Confirm::new("Would you like to begin the installation?").prompt()?
+ };
+
+ if begin_installation {
+ println!("\n{}", important_text("Starting installation..."));
+ // Run sudo apt update and sudo apt upgrade
+ step_package_updates("1.".to_string(), &sh)?;
+
+ // Install curl
+ step_install_curl("2.".to_string(), &sh)?;
+ // If user wants SSH capability, install openssh-server
+ if config.ssh {
+ step_install_openssh("2.".to_string(), &sh)?;
}
- let has_docker_compose = cmd!(sh, "grep docker-compose")
- .stdin(dpkg_l_output)
- .ignore_status()
- .output()?
- .status
- .success();
- if !has_docker_compose {
- cmd!(sh, "sudo apt install docker-compose -y").run()?;
- }
+ // Install docker CLI if user does not have it installed
+ step_install_docker("3.".to_string(), &sh, username.clone())?;
+
+ step_install_ahoy("4.".to_string(), &sh, username.clone())?;
+
+ step_install_and_run_ckan_compose("5.".to_string(), &sh, username.clone())?;
println!(
- "\n{} Installing Ahoy...",
- "4.".if_supports_color(Stdout, |t| t.style(step_style)),
- );
- sh.change_dir(format!("/home/{username}"));
- cmd!(sh, "sudo curl -LO https://github.com/ahoy-cli/ahoy/releases/download/v2.5.0/ahoy-bin-linux-amd64").run()?;
- cmd!(sh, "mv ./ahoy-bin-linux-amd64 ./ahoy").run()?;
- cmd!(sh, "sudo chmod +x ./ahoy").run()?;
- println!(
- "{}",
- "✅ 4. Successfully installed Ahoy."
- .if_supports_color(Stdout, |t| t.style(success_style))
- );
-
- println!(
- "\n{} Downloading, installing, and starting ckan-compose...",
- "5.".if_supports_color(Stdout, |t| t.style(step_style)),
- );
- if !std::fs::exists(format!("/home/{username}/ckan-compose"))? {
- cmd!(sh, "git clone https://github.com/tino097/ckan-compose.git").run()?;
- }
- sh.change_dir(format!("/home/{username}/ckan-compose"));
- cmd!(sh, "git switch solr-9-impl").run()?;
- let env_data = "PROJECT_NAME=ckan-devstaller-project
-DATASTORE_READONLY_PASSWORD=pass
-POSTGRES_PASSWORD=pass";
- std::fs::write(format!("/home/{username}/ckan-compose/.env"), env_data)?;
- cmd!(sh, "sudo ../ahoy up").run()?;
- println!(
- "{}",
- "✅ 5. Successfully ran ckan-compose."
- .if_supports_color(Stdout, |t| t.style(success_style))
- );
-
- println!(
- "\n{} Installing CKAN 2.11.3...",
- "6.".if_supports_color(Stdout, |t| t.style(step_style)),
+ "\n{} Installing CKAN {}...",
+ step_text("6."),
+ config.ckan_version
);
cmd!(sh, "sudo apt install python3-dev libpq-dev python3-pip python3-venv git-core redis-server -y").run()?;
cmd!(sh, "sudo mkdir -p /usr/lib/ckan/default").run()?;
@@ -162,16 +227,20 @@ POSTGRES_PASSWORD=pass";
let venv = VirtualEnv::with_path(&sh, &venv_path)?;
venv.pip_upgrade("pip")?;
venv.pip_install(
- "git+https://github.com/ckan/ckan.git@ckan-2.11.3#egg=ckan[requirements]",
+ format!(
+ "git+https://github.com/ckan/ckan.git@ckan-{}#egg=ckan[requirements]",
+ config.ckan_version
+ )
+ .as_str(),
)?;
cmd!(sh, "sudo mkdir -p /etc/ckan/default").run()?;
cmd!(sh, "sudo chown -R {username} /etc/ckan/").run()?;
cmd!(
sh,
- "git clone https://github.com/ckan/ckan.git /usr/lib/ckan/default/src"
+ "git clone https://github.com/ckan/ckan.git /usr/lib/ckan/default/src/ckan"
)
.run()?;
- sh.change_dir("/usr/lib/ckan/default/src");
+ sh.change_dir("/usr/lib/ckan/default/src/ckan");
cmd!(sh, "ckan generate config /etc/ckan/default/ckan.ini").run()?;
cmd!(
sh,
@@ -184,231 +253,43 @@ POSTGRES_PASSWORD=pass";
cmd!(sh, "sudo mkdir -p ckan/default").run()?;
cmd!(sh, "sudo chown {username}.{username} ckan/default").run()?;
cmd!(sh, "ckan -c /etc/ckan/default/ckan.ini db init").run()?;
- cmd!(sh, "ckan -c /etc/ckan/default/ckan.ini user add {username} password=password email={username}@localhost").run()?;
+ let sysadmin_username = config.sysadmin.username;
+ let sysadmin_password = config.sysadmin.password;
+ let sysadmin_email = config.sysadmin.email;
+ cmd!(sh, "ckan -c /etc/ckan/default/ckan.ini user add {sysadmin_username} password={sysadmin_password} email={sysadmin_email}").run()?;
cmd!(
sh,
- "ckan -c /etc/ckan/default/ckan.ini sysadmin add {username}"
+ "ckan -c /etc/ckan/default/ckan.ini sysadmin add {sysadmin_username}"
)
.run()?;
println!(
"{}",
- "✅ 6. Installed CKAN 2.11.3."
- .if_supports_color(Stdout, |t| t.style(success_style))
+ success_text(format!("6. Installed CKAN {}.", config.ckan_version).as_str())
);
- println!(
- "\n{} Enabling DataStore plugin, adding config URLs in /etc/ckan/default/ckan.ini and updating permissions...",
- "7.".if_supports_color(Stdout, |t| t.style(step_style)),
- );
- // TODO: use the ckan config-tool command instead of rust-ini
- let mut conf = ini::Ini::load_from_file("/etc/ckan/default/ckan.ini")?;
- let app_main_section = conf.section_mut(Some("app:main")).unwrap();
- let mut ckan_plugins = app_main_section.get("ckan.plugins").unwrap().to_string();
- ckan_plugins.push_str(" datastore");
- app_main_section.insert("ckan.plugins", ckan_plugins);
- app_main_section.insert(
- "ckan.datastore.write_url",
- "postgresql://ckan_default:pass@localhost/datastore_default",
- );
- app_main_section.insert(
- "ckan.datastore.read_url",
- "postgresql://datastore_default:pass@localhost/datastore_default",
- );
- app_main_section.insert("ckan.datastore.sqlsearch.enabled", "true");
- conf.write_to_file("/etc/ckan/default/ckan.ini")?;
- let postgres_container_id = cmd!(
- sh,
- "sudo docker ps -aqf name=^ckan-devstaller-project-postgres$"
- )
- .read()?;
- let set_permissions_output = cmd!(
- sh,
- "ckan -c /etc/ckan/default/ckan.ini datastore set-permissions"
- )
- .read()?;
- std::fs::write("permissions.sql", set_permissions_output)?;
- loop {
- std::thread::sleep(std::time::Duration::from_secs(2));
- if std::fs::exists("permissions.sql")? {
- break;
- }
+ // Install extensions
+ if config.extension_datastore {
+ step_install_datastore_extension("7.".to_string(), &sh, username.clone())?;
+ }
+ if config.extension_ckanext_scheming {
+ step_install_ckanext_scheming_extension("8.".to_string(), &sh, username.clone())?;
+ }
+ if config.extension_datapusher_plus {
+ step_install_datapusher_plus_extension(
+ "9.".to_string(),
+ &sh,
+ sysadmin_username,
+ username.clone(),
+ )?;
}
- sh.change_dir(format!("/home/{username}"));
- cmd!(
- sh,
- "sudo docker cp permissions.sql {postgres_container_id}:/permissions.sql"
- )
- .run()?;
- cmd!(sh, "sudo docker exec {postgres_container_id} psql -U ckan_default --set ON_ERROR_STOP=1 -f permissions.sql").run()?;
- println!(
- "{}",
- "✅ 7. Enabled DataStore plugin, set DataStore URLs in /etc/ckan/default/ckan.ini, and updated permissions."
- .if_supports_color(Stdout, |t| t.style(success_style))
- );
- println!(
- "\n{} Installing ckanext-scheming and DataPusher+ extensions...",
- "8.".if_supports_color(Stdout, |t| t.style(step_style)),
- );
- cmd!(
- sh,
- "pip install -e git+https://github.com/ckan/ckanext-scheming.git#egg=ckanext-scheming"
- )
- .run()?;
- let mut conf = ini::Ini::load_from_file("/etc/ckan/default/ckan.ini")?;
- let app_main_section = conf.section_mut(Some("app:main")).unwrap();
- let mut ckan_plugins = app_main_section.get("ckan.plugins").unwrap().to_string();
- ckan_plugins.push_str(" scheming_datasets");
- cmd!(sh, "ckan config-tool /etc/ckan/default/ckan.ini -s app:main ckan.plugins={ckan_plugins}").run()?;
- cmd!(sh, "ckan config-tool /etc/ckan/default/ckan.ini -s app:main scheming.presets=ckanext.scheming:presets.json").run()?;
- cmd!(sh, "ckan config-tool /etc/ckan/default/ckan.ini -s app:main scheming.dataset_fallback=false").run()?;
- // app_main_section.insert("ckan.plugins", ckan_plugins);
- // app_main_section.insert("scheming.presets", "ckanext.scheming:presets.json");
- // app_main_section.insert("scheming.dataset_fallback", "false");
- // conf.write_to_file("/etc/ckan/default/ckan.ini")?;
- // Install DataPusher+
- cmd!(sh, "sudo apt install python3-virtualenv python3-dev python3-pip python3-wheel build-essential libxslt1-dev libxml2-dev zlib1g-dev git libffi-dev libpq-dev uchardet -y").run()?;
- sh.change_dir("/usr/lib/ckan/default/src");
- cmd!(sh, "pip install -e git+https://github.com/dathere/datapusher-plus.git@main#egg=datapusher-plus").run()?;
- sh.change_dir("/usr/lib/ckan/default/src/datapusher-plus");
- cmd!(sh, "pip install -r requirements.txt").run()?;
- sh.change_dir(format!("/home/{username}"));
- cmd!(sh, "wget https://github.com/dathere/qsv/releases/download/4.0.0/qsv-4.0.0-x86_64-unknown-linux-gnu.zip").run()?;
- cmd!(sh, "sudo apt install unzip -y").run()?;
- cmd!(sh, "unzip qsv-4.0.0-x86_64-unknown-linux-gnu.zip").run()?;
- cmd!(sh, "sudo rm -rf qsv-4.0.0-x86_64-unknown-linux-gnu.zip").run()?;
- cmd!(sh, "sudo mv ./qsvdp_glibc-2.31 /usr/local/bin/qsvdp").run()?;
- let mut conf = ini::Ini::load_from_file("/etc/ckan/default/ckan.ini")?;
- let app_main_section = conf.section_mut(Some("app:main")).unwrap();
- let mut ckan_plugins = app_main_section.get("ckan.plugins").unwrap().to_string();
- ckan_plugins.push_str(" datapusher_plus");
- cmd!(sh, "ckan config-tool /etc/ckan/default/ckan.ini -s app:main ckan.plugins={ckan_plugins}").run()?;
- cmd!(sh, "ckan config-tool /etc/ckan/default/ckan.ini -s app:main scheming.dataset_schemas=ckanext.datapusher_plus:dataset-druf.yaml").run()?;
- // app_main_section.insert("ckan.plugins", ckan_plugins);
- // app_main_section.insert(
- // "scheming.dataset_schemas",
- // "ckanext.datapusher_plus:dataset-druf.yaml",
- // );
- // conf.write_to_file("/etc/ckan/default/ckan.ini")?;
- let dpp_default_config = r#"
-ckanext.datapusher_plus.use_proxy = false
-ckanext.datapusher_plus.download_proxy =
-ckanext.datapusher_plus.ssl_verify = false
-# supports INFO, DEBUG, TRACE - use DEBUG or TRACE when debugging scheming Formulas
-ckanext.datapusher_plus.upload_log_level = INFO
-ckanext.datapusher_plus.formats = csv tsv tab ssv xls xlsx xlsxb xlsm ods geojson shp qgis zip
-ckanext.datapusher_plus.pii_screening = false
-ckanext.datapusher_plus.pii_found_abort = false
-ckanext.datapusher_plus.pii_regex_resource_id_or_alias =
-ckanext.datapusher_plus.pii_show_candidates = false
-ckanext.datapusher_plus.pii_quick_screen = false
-ckanext.datapusher_plus.qsv_bin = /usr/local/bin/qsvdp
-ckanext.datapusher_plus.preview_rows = 100
-ckanext.datapusher_plus.download_timeout = 300
-ckanext.datapusher_plus.max_content_length = 1256000000000
-ckanext.datapusher_plus.chunk_size = 16384
-ckanext.datapusher_plus.default_excel_sheet = 0
-ckanext.datapusher_plus.sort_and_dupe_check = true
-ckanext.datapusher_plus.dedup = false
-ckanext.datapusher_plus.unsafe_prefix = unsafe_
-ckanext.datapusher_plus.reserved_colnames = _id
-ckanext.datapusher_plus.prefer_dmy = false
-ckanext.datapusher_plus.ignore_file_hash = true
-ckanext.datapusher_plus.auto_index_threshold = 3
-ckanext.datapusher_plus.auto_index_dates = true
-ckanext.datapusher_plus.auto_unique_index = true
-ckanext.datapusher_plus.summary_stats_options =
-ckanext.datapusher_plus.add_summary_stats_resource = false
-ckanext.datapusher_plus.summary_stats_with_preview = false
-ckanext.datapusher_plus.qsv_stats_string_max_length = 32767
-ckanext.datapusher_plus.qsv_dates_whitelist = date,time,due,open,close,created
-ckanext.datapusher_plus.qsv_freq_limit = 10
-ckanext.datapusher_plus.auto_alias = true
-ckanext.datapusher_plus.auto_alias_unique = false
-ckanext.datapusher_plus.copy_readbuffer_size = 1048576
-ckanext.datapusher_plus.type_mapping = {"String": "text", "Integer": "numeric","Float": "numeric","DateTime": "timestamp","Date": "date","NULL": "text"}
-ckanext.datapusher_plus.auto_spatial_simplication = true
-ckanext.datapusher_plus.spatial_simplication_relative_tolerance = 0.1
-ckanext.datapusher_plus.latitude_fields = latitude,lat
-ckanext.datapusher_plus.longitude_fields = longitude,long,lon
-ckanext.datapusher_plus.jinja2_bytecode_cache_dir = /tmp/jinja2_butecode_cache
-ckanext.datapusher_plus.auto_unzip_one_file = true
-ckanext.datapusher_plus.api_token =
-ckanext.datapusher_plus.describeGPT_api_key =
-ckanext.datapusher_plus.file_bin = /usr/bin/file
-ckanext.datapusher_plus.enable_druf = true
-ckanext.datapusher_plus.enable_form_redirect = true
-"#;
- std::fs::write("dpp_default_config.ini", dpp_default_config)?;
- cmd!(
- sh,
- "ckan config-tool /etc/ckan/default/ckan.ini -f dpp_default_config.ini"
- )
- .run()?;
- let resource_formats_str =
- std::fs::read_to_string("/usr/lib/ckan/default/src/ckan/config/resource_formats.json")?;
- let mut resource_formats_val: serde_json::Value =
- serde_json::from_str(&resource_formats_str)?;
- let all_resource_formats = resource_formats_val
- .get_mut(0)
- .unwrap()
- .as_array_mut()
- .unwrap();
- all_resource_formats.push(json!([
- "TAB",
- "Tab Separated Values File",
- "text/tab-separated-values",
- []
- ]));
- std::fs::write(
- "/usr/lib/ckan/default/src/ckan/config/resource_formats.json",
- serde_json::to_string(&resource_formats_val)?,
- )?;
- cmd!(sh, "sudo locale-gen en_US.UTF-8").run()?;
- cmd!(sh, "sudo update-locale").run()?;
- let token_command_output = cmd!(
- sh,
- "ckan -c /etc/ckan/default/ckan.ini user token add {username} dpplus"
- )
- .read()?;
- let tail_output = cmd!(sh, "tail -n 1").stdin(token_command_output).read()?;
- let dpp_api_token = cmd!(sh, "tr -d '\t'").stdin(tail_output).read()?;
- cmd!(sh, "ckan config-tool /etc/ckan/default/ckan.ini ckanext.datapusher_plus.api_token={dpp_api_token}").env("LC_ALL", "en_US.UTF-8").run()?;
- cmd!(
- sh,
- "ckan -c /etc/ckan/default/ckan.ini db upgrade -p datapusher_plus"
- )
- .run()?;
- println!(
- "{}",
- "✅ 8. Installed ckanext-scheming and DataPusher+ extensions."
- .if_supports_color(Stdout, |t| t.style(success_style))
- );
-
- println!(
- "\n{}",
- "✅ 9. Running CKAN instance...".if_supports_color(Stdout, |t| t.style(success_style))
- );
- cmd!(sh, "ckan -c /etc/ckan/default/ckan.ini run").run()?;
+ if !args.skip_run {
+ println!("\n{}", success_text("Running CKAN instance..."));
+ cmd!(sh, "ckan -c /etc/ckan/default/ckan.ini run").run()?;
+ }
+ } else {
+ println!("Cancelling installation.");
}
Ok(())
}
-
-struct Config {
- ssh: bool,
-}
-
-fn get_config_from_prompts() -> Result {
- let ssh = Confirm::new("Would you like to enable SSH? (optional)")
- .with_default(false)
- .with_help_message(
- format!(
- "This step would install {}",
- "openssh-server".if_supports_color(Stdout, |t| t.on_blue().white())
- )
- .as_str(),
- )
- .prompt()?;
- Ok(Config { ssh })
-}
diff --git a/src/questions.rs b/src/questions.rs
new file mode 100644
index 0000000..402639e
--- /dev/null
+++ b/src/questions.rs
@@ -0,0 +1,64 @@
+use crate::{Sysadmin, styles::highlighted_text};
+use anyhow::Result;
+use inquire::{Confirm, Select, Text};
+
+pub fn question_ssh() -> Result {
+ Ok(Confirm::new("Would you like to enable SSH? (optional)")
+ .with_default(false)
+ .with_help_message(
+ format!(
+ "This step would install {}",
+ highlighted_text("openssh-server")
+ )
+ .as_str(),
+ )
+ .prompt()?)
+}
+
+pub fn question_ckan_version() -> Result {
+ let ckan_version_options: Vec<&str> = vec!["2.11.4", "2.10.9", "Other"];
+ let answer_ckan_version = Select::new(
+ "What CKAN version would you like to install? (optional)",
+ ckan_version_options,
+ )
+ .with_help_message("We recommend using the latest compatible version of CKAN. Please do not choose 'Other' option unless for testing purposes as the CKAN version may not be supported and may cause a broken installation.")
+ .prompt()?;
+ if answer_ckan_version == "Other" {
+ Ok(
+ Text::new("What CKAN version would you like to install? (optional)")
+ .with_default("2.11.4")
+ .prompt()?,
+ )
+ } else {
+ Ok(answer_ckan_version.to_string())
+ }
+}
+
+pub fn question_sysadmin(username: String) -> Result {
+ let configure_sysadmin = Confirm::new("Would you like to configure the sysadmin account for your CKAN instance?")
+ .with_help_message(format!("The following values are set as defaults for the sysadmin account:\n\n- Username: {username}\n- Password: password\n- Email: {username}@localhost\n").as_str())
+ .prompt()?;
+ if configure_sysadmin {
+ let username = Text::new("What should your sysadmin username be set to?")
+ .with_default(username.clone().as_str())
+ .prompt()?;
+ let password = Text::new("What should your sysadmin password be set to?")
+ .with_default("password")
+ .with_help_message("The password must be at least 8 characters long")
+ .prompt()?;
+ let email = Text::new("What should your sysadmin email be set to?")
+ .with_default(format!("{username}@localhost").as_str())
+ .prompt()?;
+ Ok(Sysadmin {
+ username,
+ password,
+ email,
+ })
+ } else {
+ Ok(Sysadmin {
+ username: username.clone(),
+ password: "password".to_string(),
+ email: format!("{username}@localhost"),
+ })
+ }
+}
diff --git a/src/steps.rs b/src/steps.rs
new file mode 100644
index 0000000..af19091
--- /dev/null
+++ b/src/steps.rs
@@ -0,0 +1,387 @@
+use crate::styles::{highlighted_text, important_text, step_text, success_text};
+use anyhow::Result;
+use serde_json::json;
+use xshell::{Shell, cmd};
+
+pub fn step_intro() {
+ println!("Welcome to the ckan-devstaller!");
+ println!(
+ "ckan-devstaller is provided by datHere - {}\n",
+ highlighted_text("https://datHere.com"),
+ );
+ println!(
+ "This installer should assist in setting up {} from a source installation along with ckan-compose. If you have any issues, please report them at https://support.dathere.com or https://github.com/dathere/ckan-devstaller/issues.",
+ highlighted_text("CKAN 2.11.4")
+ );
+ println!(
+ "\nYou may also learn more about ckan-devstaller at https://ckan-devstaller.dathere.com."
+ );
+ println!(
+ "\n{}\n",
+ important_text(
+ "This installer is only intended for a brand new installation of Ubuntu 22.04."
+ )
+ );
+}
+
+pub fn step_package_updates(step_prefix: String, sh: &Shell) -> Result<()> {
+ println!(
+ "\n{} Running {} and {}...",
+ step_text(step_prefix.as_str()),
+ highlighted_text("sudo apt update -y"),
+ highlighted_text("sudo apt upgrade -y")
+ );
+ println!(
+ "{}",
+ important_text("You may need to provide your sudo password.")
+ );
+ cmd!(sh, "sudo apt update -y").run()?;
+ // Ignoring xrdp error with .ignore_status() for now
+ cmd!(sh, "sudo apt upgrade -y").ignore_status().run()?;
+ println!(
+ "{}",
+ success_text(
+ format!("{step_prefix} Successfully ran update and upgrade commands.").as_str()
+ )
+ );
+ Ok(())
+}
+
+pub fn step_install_curl(step_prefix: String, sh: &Shell) -> Result<()> {
+ println!(
+ "\n{} Installing {}...",
+ step_text("2."),
+ highlighted_text("curl")
+ );
+ cmd!(sh, "sudo apt install curl -y").run()?;
+ println!(
+ "{}",
+ success_text(format!("{step_prefix} Successfully installed curl.").as_str())
+ );
+ Ok(())
+}
+
+pub fn step_install_openssh(step_prefix: String, sh: &Shell) -> Result<()> {
+ println!(
+ "\n{} Installing openssh-server...",
+ step_text(step_prefix.as_str())
+ );
+ cmd!(sh, "sudo apt install openssh-server -y").run()?;
+ println!(
+ "{}",
+ success_text(format!("{step_prefix} Successfully installed openssh-server.").as_str())
+ );
+ Ok(())
+}
+
+pub fn step_install_docker(step_prefix: String, sh: &Shell, username: String) -> Result<()> {
+ let dpkg_l_output = cmd!(sh, "dpkg -l").read()?;
+ let has_docker = cmd!(sh, "grep docker")
+ .stdin(dpkg_l_output.clone())
+ .ignore_status()
+ .output()?
+ .status
+ .success();
+ if !has_docker {
+ println!("{} Installing Docker...", step_text(step_prefix.as_str()),);
+ cmd!(
+ sh,
+ "curl -fsSL https://get.docker.com -o /home/{username}/get-docker.sh"
+ )
+ .run()?;
+ cmd!(sh, "sudo sh /home/{username}/get-docker.sh").run()?;
+ println!(
+ "{}",
+ success_text(format!("{step_prefix} Successfully installed Docker.").as_str())
+ );
+ }
+ Ok(())
+}
+
+pub fn step_install_ahoy(step_prefix: String, sh: &Shell, username: String) -> Result<()> {
+ println!("\n{} Installing Ahoy...", step_text(step_prefix.as_str()),);
+ sh.change_dir(format!("/home/{username}"));
+ cmd!(sh, "sudo curl -LO https://github.com/ahoy-cli/ahoy/releases/download/v2.5.0/ahoy-bin-linux-amd64").run()?;
+ cmd!(sh, "mv ./ahoy-bin-linux-amd64 ./ahoy").run()?;
+ cmd!(sh, "sudo chmod +x ./ahoy").run()?;
+ println!(
+ "{}",
+ success_text(format!("{step_prefix} Successfully installed Ahoy.").as_str())
+ );
+ Ok(())
+}
+
+pub fn step_install_and_run_ckan_compose(
+ step_prefix: String,
+ sh: &Shell,
+ username: String,
+) -> Result<()> {
+ println!(
+ "\n{} Downloading, installing, and starting ckan-compose...",
+ step_text(step_prefix.as_str()),
+ );
+ if !std::fs::exists(format!("/home/{username}/ckan-compose"))? {
+ cmd!(sh, "git clone https://github.com/tino097/ckan-compose.git").run()?;
+ }
+ sh.change_dir(format!("/home/{username}/ckan-compose"));
+ cmd!(sh, "git switch ckan-devstaller").run()?;
+ let env_data = "PROJECT_NAME=ckan-devstaller-project
+DATASTORE_READONLY_PASSWORD=pass
+POSTGRES_PASSWORD=pass";
+ std::fs::write(format!("/home/{username}/ckan-compose/.env"), env_data)?;
+ cmd!(sh, "sudo ../ahoy up").run()?;
+ println!(
+ "{}",
+ success_text(format!("{step_prefix} Successfully ran ckan-compose.").as_str())
+ );
+ Ok(())
+}
+
+pub fn step_install_datastore_extension(
+ step_prefix: String,
+ sh: &Shell,
+ username: String,
+) -> Result<()> {
+ println!(
+ "\n{} Enabling DataStore plugin, adding config URLs in /etc/ckan/default/ckan.ini and updating permissions...",
+ step_text(step_prefix.as_str()),
+ );
+ let mut conf = ini::Ini::load_from_file("/etc/ckan/default/ckan.ini")?;
+ let app_main_section = conf.section_mut(Some("app:main")).unwrap();
+ let mut ckan_plugins = app_main_section.get("ckan.plugins").unwrap().to_string();
+ ckan_plugins.push_str(" datastore");
+ app_main_section.insert("ckan.plugins", ckan_plugins);
+ app_main_section.insert(
+ "ckan.datastore.write_url",
+ "postgresql://ckan_default:pass@localhost/datastore_default",
+ );
+ app_main_section.insert(
+ "ckan.datastore.read_url",
+ "postgresql://datastore_default:pass@localhost/datastore_default",
+ );
+ app_main_section.insert("ckan.datastore.sqlsearch.enabled", "true");
+ conf.write_to_file("/etc/ckan/default/ckan.ini")?;
+ let postgres_container_id = cmd!(
+ sh,
+ "sudo docker ps -aqf name=^ckan-devstaller-project-postgres$"
+ )
+ .read()?;
+ let set_permissions_output = cmd!(
+ sh,
+ "ckan -c /etc/ckan/default/ckan.ini datastore set-permissions"
+ )
+ .read()?;
+ std::fs::write("permissions.sql", set_permissions_output)?;
+ loop {
+ std::thread::sleep(std::time::Duration::from_secs(2));
+ if std::fs::exists("permissions.sql")? {
+ break;
+ }
+ }
+ sh.change_dir(format!("/home/{username}"));
+ cmd!(
+ sh,
+ "sudo docker cp permissions.sql {postgres_container_id}:/permissions.sql"
+ )
+ .run()?;
+ cmd!(sh, "sudo docker exec {postgres_container_id} psql -U ckan_default --set ON_ERROR_STOP=1 -f permissions.sql").run()?;
+ println!(
+ "{}",
+ success_text(
+ format!("{step_prefix} Enabled DataStore plugin, set DataStore URLs in /etc/ckan/default/ckan.ini, and updated permissions.").as_str()
+ )
+ );
+ Ok(())
+}
+
+pub fn step_install_ckanext_scheming_extension(
+ step_prefix: String,
+ sh: &Shell,
+ username: String,
+) -> Result<()> {
+ println!(
+ "{}",
+ step_text("\n{} Installing the ckanext-scheming extension..."),
+ );
+ cmd!(
+ sh,
+ "pip install -e git+https://github.com/ckan/ckanext-scheming.git#egg=ckanext-scheming"
+ )
+ .run()?;
+ let mut conf = ini::Ini::load_from_file("/etc/ckan/default/ckan.ini")?;
+ let app_main_section = conf.section_mut(Some("app:main")).unwrap();
+ let mut ckan_plugins = app_main_section.get("ckan.plugins").unwrap().to_string();
+ ckan_plugins.push_str(" scheming_datasets");
+ cmd!(
+ sh,
+ "ckan config-tool /etc/ckan/default/ckan.ini -s app:main ckan.plugins={ckan_plugins}"
+ )
+ .run()?;
+ cmd!(sh, "ckan config-tool /etc/ckan/default/ckan.ini -s app:main scheming.presets=ckanext.scheming:presets.json").run()?;
+ cmd!(
+ sh,
+ "ckan config-tool /etc/ckan/default/ckan.ini -s app:main scheming.dataset_fallback=false"
+ )
+ .run()?;
+ // app_main_section.insert("ckan.plugins", ckan_plugins);
+ // app_main_section.insert("scheming.presets", "ckanext.scheming:presets.json");
+ // app_main_section.insert("scheming.dataset_fallback", "false");
+ // conf.write_to_file("/etc/ckan/default/ckan.ini")?;
+ Ok(())
+}
+
+pub fn step_install_datapusher_plus_extension(
+ step_prefix: String,
+ sh: &Shell,
+ sysadmin_username: String,
+ username: String,
+) -> Result<()> {
+ // Install DataPusher+
+ println!(
+ "{}",
+ step_text(format!("\n{step_prefix} Installing DataPusher+ extension...").as_str())
+ );
+ cmd!(sh, "sudo apt install python3-virtualenv python3-dev python3-pip python3-wheel build-essential libxslt1-dev libxml2-dev zlib1g-dev git libffi-dev libpq-dev uchardet -y").run()?;
+ sh.change_dir("/usr/lib/ckan/default/src");
+ cmd!(
+ sh,
+ "pip install -e git+https://github.com/dathere/datapusher-plus.git@main#egg=datapusher-plus"
+ )
+ .run()?;
+ sh.change_dir("/usr/lib/ckan/default/src/datapusher-plus");
+ cmd!(sh, "pip install -r requirements.txt").run()?;
+ sh.change_dir(format!("/home/{username}"));
+ cmd!(sh, "wget https://github.com/dathere/qsv/releases/download/4.0.0/qsv-4.0.0-x86_64-unknown-linux-gnu.zip").run()?;
+ cmd!(sh, "sudo apt install unzip -y").run()?;
+ cmd!(sh, "unzip qsv-4.0.0-x86_64-unknown-linux-gnu.zip").run()?;
+ cmd!(sh, "sudo rm -rf qsv-4.0.0-x86_64-unknown-linux-gnu.zip").run()?;
+ cmd!(sh, "sudo mv ./qsvdp_glibc-2.31 /usr/local/bin/qsvdp").run()?;
+ let mut conf = ini::Ini::load_from_file("/etc/ckan/default/ckan.ini")?;
+ let app_main_section = conf.section_mut(Some("app:main")).unwrap();
+ let mut ckan_plugins = app_main_section.get("ckan.plugins").unwrap().to_string();
+ ckan_plugins.push_str(" datapusher_plus");
+ app_main_section.insert("ckan.plugins", ckan_plugins);
+ app_main_section.insert(
+ "scheming.dataset_schemas",
+ "ckanext.datapusher_plus:dataset-druf.yaml",
+ );
+ app_main_section.insert("ckanext.datapusher_plus.use_proxy", "false");
+ app_main_section.insert("ckanext.datapusher_plus.download_proxy", "");
+ app_main_section.insert("ckanext.datapusher_plus.ssl_verify", "false");
+ app_main_section.insert("ckanext.datapusher_plus.upload_log_level", "INFO");
+ app_main_section.insert(
+ "ckanext.datapusher_plus.formats",
+ "csv tsv tab ssv xls xlsx xlsxb xlsm ods geojson shp qgis zip",
+ );
+ app_main_section.insert("ckanext.datapusher_plus.pii_screening", "false");
+ app_main_section.insert("ckanext.datapusher_plus.pii_found_abort", "false");
+ app_main_section.insert("ckanext.datapusher_plus.pii_regex_resource_id_or_alias", "");
+ app_main_section.insert("ckanext.datapusher_plus.pii_show_candidates", "false");
+ app_main_section.insert("ckanext.datapusher_plus.pii_quick_screen", "false");
+ app_main_section.insert("ckanext.datapusher_plus.qsv_bin", "/usr/local/bin/qsvdp");
+ app_main_section.insert("ckanext.datapusher_plus.preview_rows", "100");
+ app_main_section.insert("ckanext.datapusher_plus.download_timeout", "300");
+ app_main_section.insert(
+ "ckanext.datapusher_plus.max_content_length",
+ "1256000000000",
+ );
+ app_main_section.insert("ckanext.datapusher_plus.chunk_size", "16384");
+ app_main_section.insert("ckanext.datapusher_plus.default_excel_sheet", "0");
+ app_main_section.insert("ckanext.datapusher_plus.sort_and_dupe_check", "true");
+ app_main_section.insert("ckanext.datapusher_plus.dedup", "false");
+ app_main_section.insert("ckanext.datapusher_plus.unsafe_prefix", "unsafe_");
+ app_main_section.insert("ckanext.datapusher_plus.reserved_colnames", "_id");
+ app_main_section.insert("ckanext.datapusher_plus.prefer_dmy", "false");
+ app_main_section.insert("ckanext.datapusher_plus.ignore_file_hash", "true");
+ app_main_section.insert("ckanext.datapusher_plus.auto_index_threshold", "3");
+ app_main_section.insert("ckanext.datapusher_plus.auto_index_dates", "true");
+ app_main_section.insert("ckanext.datapusher_plus.auto_unique_index", "true");
+ app_main_section.insert("ckanext.datapusher_plus.summary_stats_options", "");
+ app_main_section.insert(
+ "ckanext.datapusher_plus.add_summary_stats_resource",
+ "false",
+ );
+ app_main_section.insert(
+ "ckanext.datapusher_plus.summary_stats_with_preview",
+ "false",
+ );
+ app_main_section.insert(
+ "ckanext.datapusher_plus.qsv_stats_string_max_length",
+ "32767",
+ );
+ app_main_section.insert(
+ "ckanext.datapusher_plus.qsv_dates_whitelist",
+ "date,time,due,open,close,created",
+ );
+ app_main_section.insert("ckanext.datapusher_plus.qsv_freq_limit", "10");
+ app_main_section.insert("ckanext.datapusher_plus.auto_alias", "true");
+ app_main_section.insert("ckanext.datapusher_plus.auto_alias_unique", "false");
+ app_main_section.insert("ckanext.datapusher_plus.copy_readbuffer_size", "1048576");
+ app_main_section.insert("ckanext.datapusher_plus.type_mapping", r#"{"String": "text", "Integer": "numeric","Float": "numeric","DateTime": "timestamp","Date": "date","NULL": "text"}"#);
+ app_main_section.insert("ckanext.datapusher_plus.auto_spatial_simplication", "true");
+ app_main_section.insert(
+ "ckanext.datapusher_plus.spatial_simplication_relative_tolerance",
+ "0.1",
+ );
+ app_main_section.insert("ckanext.datapusher_plus.latitude_fields", "latitude,lat");
+ app_main_section.insert(
+ "ckanext.datapusher_plus.longitude_fields",
+ "longitude,long,lon",
+ );
+ app_main_section.insert(
+ "ckanext.datapusher_plus.jinja2_bytecode_cache_dir",
+ "/tmp/jinja2_butecode_cache",
+ );
+ app_main_section.insert("ckanext.datapusher_plus.auto_unzip_one_file", "true");
+ app_main_section.insert(
+ "ckanext.datapusher_plus.api_token",
+ "",
+ );
+ app_main_section.insert(
+ "ckanext.datapusher_plus.describeGPT_api_key",
+ "",
+ );
+ app_main_section.insert("ckanext.datapusher_plus.file_bin", "/usr/bin/file");
+ app_main_section.insert("ckanext.datapusher_plus.enable_druf", "false");
+ app_main_section.insert("ckanext.datapusher_plus.enable_form_redirect", "true");
+ conf.write_to_file("/etc/ckan/default/ckan.ini")?;
+ let resource_formats_str = std::fs::read_to_string(
+ "/usr/lib/ckan/default/src/ckan/ckan/config/resource_formats.json",
+ )?;
+ let mut resource_formats_val: serde_json::Value = serde_json::from_str(&resource_formats_str)?;
+ let all_resource_formats = resource_formats_val
+ .get_mut(0)
+ .unwrap()
+ .as_array_mut()
+ .unwrap();
+ all_resource_formats.push(json!([
+ "TAB",
+ "Tab Separated Values File",
+ "text/tab-separated-values",
+ []
+ ]));
+ std::fs::write(
+ "/usr/lib/ckan/default/src/ckan/ckan/config/resource_formats.json",
+ serde_json::to_string(&resource_formats_val)?,
+ )?;
+ cmd!(sh, "sudo locale-gen en_US.UTF-8").run()?;
+ cmd!(sh, "sudo update-locale").run()?;
+ let token_command_output = cmd!(
+ sh,
+ "ckan -c /etc/ckan/default/ckan.ini user token add {sysadmin_username} dpplus"
+ )
+ .read()?;
+ let tail_output = cmd!(sh, "tail -n 1").stdin(token_command_output).read()?;
+ let dpp_api_token = cmd!(sh, "tr -d '\t'").stdin(tail_output).read()?;
+ cmd!(sh, "ckan config-tool /etc/ckan/default/ckan.ini ckanext.datapusher_plus.api_token={dpp_api_token}").env("LC_ALL", "en_US.UTF-8").run()?;
+ cmd!(
+ sh,
+ "ckan -c /etc/ckan/default/ckan.ini db upgrade -p datapusher_plus"
+ )
+ .run()?;
+ println!(
+ "{}",
+ success_text(format!("{step_prefix} Installed DataPusher+ extension.").as_str())
+ );
+ Ok(())
+}
diff --git a/src/styles.rs b/src/styles.rs
new file mode 100644
index 0000000..11234dd
--- /dev/null
+++ b/src/styles.rs
@@ -0,0 +1,29 @@
+use owo_colors::{OwoColorize, Stream::Stdout};
+
+pub fn highlighted_text(text: &str) -> impl std::fmt::Display {
+ format!(
+ "{}",
+ text.if_supports_color(Stdout, |t| t.on_blue().white())
+ )
+}
+
+pub fn important_text(text: &str) -> impl std::fmt::Display {
+ format!(
+ "{}",
+ text.if_supports_color(Stdout, |t| t.on_bright_red().white())
+ )
+}
+
+pub fn step_text(text: &str) -> impl std::fmt::Display {
+ format!(
+ "{}",
+ text.if_supports_color(Stdout, |t| t.on_magenta().white())
+ )
+}
+
+pub fn success_text(text: &str) -> impl std::fmt::Display {
+ format!(
+ "{}",
+ text.if_supports_color(Stdout, |t| t.on_green().white())
+ )
+}