startnet editor v2: settings/menu forms, syntax-highlight+lint, backup/restore+diff

Rebuilt the /startnet editor (Fable/Opus 4-stage build) into tabs:
- Settings: server IP (+ menu timeout/default when a choice construct exists) as form fields; apply rewrites only the targeted tokens.
- Boot Menu: add/remove/reorder image entries; regenerates only the menu echo/dispatch + action blocks, refuses reorders that would desync the enrollment %choice% router.
- Raw: full-text editor (still source of truth) with line-number gutter, batch syntax highlighting, a lint panel (unmatched goto/label, CRLF), and diff-vs-current.
- History: timestamped snapshots on every save, per-row diff + restore.
New wim.py helpers (framework-free): parse/apply_settings, parse/apply_boot_menu, lint_startnet, save/list/read_snapshot, unified_diff; BACKUPS_DIR=/var/lib/pxe-webapp/startnet-backups.
Fable review fixed a CRITICAL pre-existing bug: update_startnet's newline=CRLF write retranslated posted CRLF into \r\r\n, corrupting boot.wim on every raw save; now normalizes to LF first. Also fixed a false CRLF lint warning (verbatim read) and menu payload validation. All JS inline (no CDN).
This commit is contained in:
cproudlock
2026-07-23 11:37:24 -04:00
parent 234295931e
commit cc391529bd
3 changed files with 1581 additions and 121 deletions

View File

@@ -932,29 +932,89 @@ def enrollment_delete(filename):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routes - startnet.cmd Editor (boot.wim) # Routes - startnet.cmd Editor (boot.wim)
#
# The raw full-text editor (POST /startnet/save) is the source of truth and the
# always-available fallback. The Settings and Boot-menu tabs are conveniences
# that parse the CURRENT extracted content to prefill, then round-trip an
# apply_* result back through the SAME wim.update_startnet save path. Every save
# path snapshots the prior startnet.cmd first (feature 4) so a bad edit can be
# rolled back from the Snapshots tab.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _startnet_wiminfo():
"""Parse `wiminfo boot.wim` into a flat dict (empty on any failure)."""
import subprocess
info = {}
try:
result = subprocess.run(
["wiminfo", config.BOOT_WIM],
capture_output=True, text=True, timeout=15,
)
if result.returncode == 0:
for line in result.stdout.splitlines():
if ":" in line:
key, _, val = line.partition(":")
info[key.strip()] = val.strip()
except Exception:
pass
return info
def _startnet_current():
"""Extracted startnet.cmd text from boot.wim, or '' if unavailable."""
if not os.path.isfile(config.BOOT_WIM):
return ""
return wim.extract_startnet(config.BOOT_WIM) or ""
def _startnet_save_content(new_content, prior, action, detail):
"""Snapshot `prior` then write `new_content` to boot.wim via wimtools.
Central save path shared by the raw editor, the settings tab, the menu tab,
and snapshot-restore so EVERY write snapshots first. Flashes success/failure
and returns True on success. Lint warnings are surfaced but never block.
"""
try:
wim.save_snapshot(prior, note=action)
except Exception as exc:
# A snapshot failure should not silently drop the safety net; warn but
# still let the operator save (the raw editor is the source of truth).
flash(f"Warning: could not snapshot prior startnet.cmd: {exc}", "warning")
ok, err = wim.update_startnet(config.BOOT_WIM, new_content)
if not ok:
flash(f"Failed to update boot.wim: {err}", "danger")
return False
lint = wim.lint_startnet(new_content)
for e in lint.get("errors", []):
loc = f"line {e['line']}: " if e.get("line") else ""
flash(f"Lint error - {loc}{e['message']}", "warning")
for w in lint.get("warnings", []):
loc = f"line {w['line']}: " if w.get("line") else ""
flash(f"Lint warning - {loc}{w['message']}", "warning")
audit(action, detail)
return True
@app.route("/startnet") @app.route("/startnet")
def startnet_editor(): def startnet_editor():
import subprocess
wim_exists = os.path.isfile(config.BOOT_WIM) wim_exists = os.path.isfile(config.BOOT_WIM)
content = "" content = ""
wim_info = {} wim_info = {}
settings = {"server_ip": None, "menu_timeout": None, "menu_default": None}
boot_menu = []
lint = {"errors": [], "warnings": []}
if wim_exists: if wim_exists:
content = wim.extract_startnet(config.BOOT_WIM) or "" content = _startnet_current()
try: wim_info = _startnet_wiminfo()
result = subprocess.run( settings = wim.parse_settings(content)
["wiminfo", config.BOOT_WIM], boot_menu = wim.parse_boot_menu(content)
capture_output=True, text=True, timeout=15, lint = wim.lint_startnet(content)
)
if result.returncode == 0: snapshots = wim.list_snapshots()
for line in result.stdout.splitlines():
if ":" in line:
key, _, val = line.partition(":")
wim_info[key.strip()] = val.strip()
except Exception:
pass
return render_template( return render_template(
"startnet_editor.html", "startnet_editor.html",
@@ -962,6 +1022,10 @@ def startnet_editor():
wim_path=config.BOOT_WIM, wim_path=config.BOOT_WIM,
content=content, content=content,
wim_info=wim_info, wim_info=wim_info,
settings=settings,
boot_menu=boot_menu,
lint=lint,
snapshots=snapshots,
image_types=config.IMAGE_TYPES, image_types=config.IMAGE_TYPES,
friendly_names=config.FRIENDLY_NAMES, friendly_names=config.FRIENDLY_NAMES,
) )
@@ -974,15 +1038,157 @@ def startnet_save():
return redirect(url_for("startnet_editor")) return redirect(url_for("startnet_editor"))
content = request.form.get("content", "") content = request.form.get("content", "")
ok, err = wim.update_startnet(config.BOOT_WIM, content) prior = _startnet_current()
if ok: if _startnet_save_content(content, prior, "STARTNET_SAVE", "boot.wim updated (raw)"):
audit("STARTNET_SAVE", "boot.wim updated")
flash("startnet.cmd updated successfully in boot.wim.", "success") flash("startnet.cmd updated successfully in boot.wim.", "success")
else:
flash(f"Failed to update boot.wim: {err}", "danger")
return redirect(url_for("startnet_editor")) return redirect(url_for("startnet_editor"))
@app.route("/startnet/settings", methods=["POST"])
def startnet_save_settings():
"""Apply the Settings tab into the CURRENT extracted content, then save.
Only server_ip / menu_timeout / menu_default are touched, via targeted
replacement (apply_settings never rebuilds the file). Blank fields are left
alone. The raw text stays the source of truth: we apply into what is on
boot.wim right now, not a rebuilt file.
"""
if not os.path.isfile(config.BOOT_WIM):
flash("boot.wim not found.", "danger")
return redirect(url_for("startnet_editor"))
prior = _startnet_current()
settings = {}
ip = (request.form.get("server_ip") or "").strip()
if ip:
settings["server_ip"] = ip
to = (request.form.get("menu_timeout") or "").strip()
if to:
try:
settings["menu_timeout"] = int(to)
except ValueError:
flash("Menu timeout must be a whole number of seconds.", "danger")
return redirect(url_for("startnet_editor"))
md = (request.form.get("menu_default") or "").strip()
if md:
settings["menu_default"] = md
new_content = wim.apply_settings(prior, settings)
if new_content == prior:
flash("No settings changed.", "info")
return redirect(url_for("startnet_editor"))
if _startnet_save_content(new_content, prior, "STARTNET_SETTINGS",
f"settings applied: {settings}"):
flash("Startnet settings applied and saved to boot.wim.", "success")
return redirect(url_for("startnet_editor"))
@app.route("/startnet/menu", methods=["POST"])
def startnet_save_menu():
"""Apply the Boot-menu tab into the CURRENT extracted content, then save.
The tab posts an `items` JSON array of {label, target, target_image}; list
ORDER defines the new 1..N numbering (any 'num' is ignored). apply_boot_menu
regenerates only the echo lines, choice count, dispatch, and action blocks;
existing block bodies are byte-for-byte preserved. It RAISES ValueError when
the structure is ambiguous or a renumber would desync the enrollment
%choice% router (e.g. reorder/remove of a cross-referenced image); we catch
it, flash the reason, and leave the raw text untouched.
"""
if not os.path.isfile(config.BOOT_WIM):
flash("boot.wim not found.", "danger")
return redirect(url_for("startnet_editor"))
payload = request.form.get("items", "[]")
try:
items = json.loads(payload)
except json.JSONDecodeError:
flash("Invalid boot-menu payload.", "danger")
return redirect(url_for("startnet_editor"))
if not isinstance(items, list):
flash("Boot-menu payload must be a list of items.", "danger")
return redirect(url_for("startnet_editor"))
for it in items:
if not isinstance(it, dict) or not (it.get("target") or "").strip():
flash("Every boot-menu entry needs a non-empty Target.", "danger")
return redirect(url_for("startnet_editor"))
prior = _startnet_current()
try:
new_content = wim.apply_boot_menu(prior, items)
except ValueError as exc:
flash(f"Boot menu not rewritten (kept raw text): {exc}", "danger")
return redirect(url_for("startnet_editor"))
if new_content == prior:
flash("No boot-menu changes to save.", "info")
return redirect(url_for("startnet_editor"))
if _startnet_save_content(new_content, prior, "STARTNET_MENU",
f"boot menu rebuilt ({len(items)} item(s))"):
flash("Boot menu rebuilt and saved to boot.wim.", "success")
return redirect(url_for("startnet_editor"))
@app.route("/startnet/restore", methods=["POST"])
def startnet_restore_snapshot():
"""Restore a snapshot back through the save path.
Reads the snapshot (path-traversal safe), snapshots the ABOUT-TO-BE-REPLACED
current startnet first, then writes the restored bytes to boot.wim.
"""
if not os.path.isfile(config.BOOT_WIM):
flash("boot.wim not found.", "danger")
return redirect(url_for("startnet_editor"))
snapshot_id = (request.form.get("snapshot_id") or "").strip()
restored = wim.read_snapshot(snapshot_id)
if restored is None:
flash(f"Snapshot not found: {snapshot_id}", "danger")
return redirect(url_for("startnet_editor"))
prior = _startnet_current()
if _startnet_save_content(restored, prior, "STARTNET_RESTORE",
f"restored snapshot {snapshot_id}"):
flash(f"Restored startnet.cmd from {snapshot_id}.", "success")
return redirect(url_for("startnet_editor"))
@app.route("/startnet/diff", methods=["POST"])
def startnet_diff():
"""Return a unified diff as JSON. Two modes (body may be form or JSON):
- snapshot_id -> diff that snapshot (fromfile) vs current boot.wim (tofile)
- content -> diff current boot.wim (fromfile) vs pending edit (tofile)
JSON callers must send the CSRF token in the X-CSRF-Token header.
"""
body = request.get_json(silent=True) or {}
snapshot_id = (request.form.get("snapshot_id") or body.get("snapshot_id") or "").strip()
pending = request.form.get("content")
if pending is None:
pending = body.get("content")
current = _startnet_current()
if snapshot_id:
snap = wim.read_snapshot(snapshot_id)
if snap is None:
return jsonify({"error": f"snapshot not found: {snapshot_id}"}), 404
diff = wim.unified_diff(snap, current, fromfile=snapshot_id, tofile="current")
return jsonify({"diff": diff, "mode": "snapshot", "snapshot_id": snapshot_id})
if pending is not None:
diff = wim.unified_diff(current, pending, fromfile="current", tofile="pending")
return jsonify({"diff": diff, "mode": "pending"})
return jsonify({"error": "provide snapshot_id or content"}), 400
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routes - Audit Log # Routes - Audit Log
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -1,13 +1,39 @@
"""boot.wim manipulation via wimtools (wimextract / wimupdate / wimdir). """boot.wim manipulation via wimtools (wimextract / wimupdate / wimdir)
plus pure-Python helpers for the startnet.cmd editor.
Used by the startnet.cmd editor to extract + update the boot script Two layers live here:
that runs when WinPE boots from PXE.
1. wimtools wrappers (extract_startnet / update_startnet / list_files) that
read + write startnet.cmd inside boot.wim. update_startnet writes CRLF.
2. Framework-free text helpers used by the rebuilt startnet.cmd editor:
settings-as-forms (parse_settings / apply_settings), the boot-menu builder
(parse_boot_menu / apply_boot_menu), lint_startnet, timestamped snapshots
(save_snapshot / list_snapshots / read_snapshot) and unified_diff.
The helpers hold NO Flask imports on purpose; the routes stage wires them up.
startnet.cmd is a WinPE BATCH script with CRLF endings. Every regen helper
preserves the file's existing line ending and only rewrites the exact lines it
targets, leaving everything else byte-for-byte. When a structure cannot be
located unambiguously the regen helpers raise ValueError so the caller can fall
back to the raw full-text editor (the source of truth).
""" """
import datetime
import difflib
import glob
import os import os
import re
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
from collections import Counter
# Root-writable dir for timestamped startnet.cmd snapshots. Overridable by env
# so tests / alt deploys can redirect it. save_snapshot() creates it on demand.
BACKUPS_DIR = os.environ.get(
"STARTNET_BACKUPS_DIR", "/var/lib/pxe-webapp/startnet-backups"
)
def extract_startnet(wim_path): def extract_startnet(wim_path):
@@ -22,7 +48,11 @@ def extract_startnet(wim_path):
) )
startnet_path = os.path.join(tmpdir, "startnet.cmd") startnet_path = os.path.join(tmpdir, "startnet.cmd")
if result.returncode == 0 and os.path.isfile(startnet_path): if result.returncode == 0 and os.path.isfile(startnet_path):
with open(startnet_path, "r", encoding="utf-8", errors="replace") as fh: # newline="" keeps the file's CRLF bytes verbatim; default universal
# newlines would fold CRLF to LF, breaking the lint CRLF check and
# storing LF-only snapshots.
with open(startnet_path, "r", encoding="utf-8", errors="replace",
newline="") as fh:
return fh.read() return fh.read()
return None return None
except Exception: except Exception:
@@ -38,6 +68,10 @@ def update_startnet(wim_path, content):
""" """
tmpdir = tempfile.mkdtemp() tmpdir = tempfile.mkdtemp()
try: try:
# Normalize to LF first: newline="\r\n" translates each bare \n, so
# CRLF input (browser textareas post CRLF) would otherwise become
# \r\r\n. After normalizing, output is always clean CRLF.
content = content.replace("\r\n", "\n").replace("\r", "\n")
startnet_path = os.path.join(tmpdir, "startnet.cmd") startnet_path = os.path.join(tmpdir, "startnet.cmd")
with open(startnet_path, "w", encoding="utf-8", newline="\r\n") as fh: with open(startnet_path, "w", encoding="utf-8", newline="\r\n") as fh:
fh.write(content) fh.write(content)
@@ -68,3 +102,626 @@ def list_files(wim_path, path="/"):
return [] return []
except Exception: except Exception:
return [] return []
# ===========================================================================
# Pure text helpers for the startnet.cmd editor (no Flask, no wimtools).
# All operate on the startnet.cmd text. Regen helpers preserve the file's
# existing line ending and only touch their target lines.
# ===========================================================================
def _detect_eol(content):
"""Return the dominant line ending, defaulting to CRLF for WinPE batch."""
if "\r\n" in content:
return "\r\n"
if "\n" in content:
return "\n"
return "\r\n"
def _split_lines(content):
"""Split into lines WITHOUT terminators, tolerant of CRLF/LF/CR.
Rejoining with eol.join(...) reproduces a trailing newline because a
trailing terminator yields a final empty element.
"""
return content.replace("\r\n", "\n").replace("\r", "\n").split("\n")
# --- Shared regexes ---------------------------------------------------------
_LABEL_RE = re.compile(r"^\s*:([A-Za-z0-9_][A-Za-z0-9_\-]*)\s*$")
_NETUSE_IMG_RE = re.compile(
r"net use\s+\w+:\s+\\\\[0-9.]+\\winpeapps\\([A-Za-z0-9_\-]+)", re.IGNORECASE
)
_GOTO_END_RE = re.compile(r"^\s*goto\s+end\s*$", re.IGNORECASE)
_DISPATCH_RE = re.compile(
r'^(\s*)if\s+"%choice%"=="(\d+)"\s+goto\s+([A-Za-z0-9_\-]+)\s*$', re.IGNORECASE
)
_ECHO_NUM_RE = re.compile(r"^(\s*)echo\s+(\d+)\.\s?(.*?)\s*$", re.IGNORECASE)
_CHOICE_PROMPT_RE = re.compile(
r"^(\s*set\s+/p\s+choice=.*\(1-)(\d+)(\).*)$", re.IGNORECASE
)
# ---------------------------------------------------------------------------
# Feature 1: settings as forms
# ---------------------------------------------------------------------------
def _detect_server_ip(content):
"""Best-effort PXE server IP: the IP most used in \\\\IP\\ UNC paths.
Falls back to a `ping <ip>` target. Returns the dotted-quad or None.
"""
ips = re.findall(r"\\\\(\d{1,3}(?:\.\d{1,3}){3})\\", content)
if not ips:
ips = re.findall(
r"ping\b[^\r\n]*?\b(\d{1,3}(?:\.\d{1,3}){3})\b", content, re.IGNORECASE
)
if not ips:
return None
return Counter(ips).most_common(1)[0][0]
def _replace_server_ip(content, old, new):
"""Replace old IP with new everywhere it stands as a whole IPv4 token.
The negative look-around on [\\d.] keeps it from matching a fragment of a
longer dotted number, and only the detected server IP is ever passed in, so
unrelated addresses (e.g. 8.8.8.8 in the enroll.cmd here-doc) are untouched.
This covers UNC (\\\\IP\\...), the ping wait-for-network line, and any URL.
"""
pat = re.compile(r"(?<![\d.])" + re.escape(old) + r"(?![\d.])")
return pat.sub(new, content)
def _detect_menu_timeout_default(content):
"""Pull timeout seconds + default key off a `choice /t N /d X` line.
startnet uses `set /p` (no timeout) today, so this is usually (None, None).
Only a real `choice` command is recognised; nothing is invented.
"""
timeout = None
default = None
for line in _split_lines(content):
if re.search(r"\bchoice\b", line, re.IGNORECASE) and "/t" in line.lower():
mt = re.search(r"/t\s*:?\s*(\d+)", line, re.IGNORECASE)
md = re.search(r"/d\s*:?\s*([A-Za-z0-9]+)", line, re.IGNORECASE)
if mt:
timeout = int(mt.group(1))
if md:
default = md.group(1)
break
return timeout, default
def parse_settings(content):
"""content -> settings dict of safe, common knobs.
Keys:
server_ip str|None PXE server IP used in UNC/ping paths
menu_timeout int|None seconds, from a `choice /t` construct if present
menu_default str|None default choice key, from `choice /d` if present
"""
ip = _detect_server_ip(content)
timeout, default = _detect_menu_timeout_default(content)
return {"server_ip": ip, "menu_timeout": timeout, "menu_default": default}
def apply_settings(content, settings):
"""settings dict -> content, via targeted replacement only.
- server_ip: if given and different from the current detected IP, rewrites
that IP everywhere it appears as a whole IPv4 token (UNC, ping, URL).
- menu_timeout / menu_default: updated ONLY if a `choice /t../d..` construct
already exists; never injected (conservative - avoids risky rewrites).
Unknown/None keys are left alone. Line endings are preserved.
"""
out = content
new_ip = settings.get("server_ip")
if new_ip:
cur = _detect_server_ip(content)
if cur and cur != new_ip:
out = _replace_server_ip(out, cur, new_ip)
if settings.get("menu_timeout") is not None or settings.get("menu_default") is not None:
out = _apply_menu_timeout_default(
out, settings.get("menu_timeout"), settings.get("menu_default")
)
return out
def _apply_menu_timeout_default(content, timeout, default):
"""Rewrite /t and /d on an existing `choice` line only. No-op otherwise."""
eol = _detect_eol(content)
lines = _split_lines(content)
for i, line in enumerate(lines):
if re.search(r"\bchoice\b", line, re.IGNORECASE) and "/t" in line.lower():
if timeout is not None:
line = re.sub(
r"(/t\s*:?\s*)\d+", r"\g<1>" + str(timeout), line, flags=re.IGNORECASE
)
if default is not None:
line = re.sub(
r"(/d\s*:?\s*)[A-Za-z0-9]+",
r"\g<1>" + str(default),
line,
flags=re.IGNORECASE,
)
lines[i] = line
break
return eol.join(lines)
# ---------------------------------------------------------------------------
# Feature 2: boot-menu builder
# ---------------------------------------------------------------------------
def _find_label_line(lines, name):
name = name.lower()
for i, line in enumerate(lines):
m = _LABEL_RE.match(line)
if m and m.group(1).lower() == name:
return i
return None
def _find_image_blocks(lines):
"""Find per-image action blocks: a ':<target>' label whose body maps
Z: to \\\\IP\\winpeapps\\<image> and ends with 'goto end'.
Returns list of dicts: {target, image, start, end} where start is the label
line index and end is the 'goto end' line index (inclusive block span).
"""
label_idxs = [i for i, l in enumerate(lines) if _LABEL_RE.match(l)]
label_set = set(label_idxs)
blocks = []
for i in label_idxs:
target = _LABEL_RE.match(lines[i]).group(1)
# body runs to the next label or EOF
end_bound = len(lines)
for j in range(i + 1, len(lines)):
if j in label_set:
end_bound = j
break
image = None
goto_end_idx = None
for j in range(i + 1, end_bound):
if image is None:
mi = _NETUSE_IMG_RE.search(lines[j])
if mi:
image = mi.group(1)
if _GOTO_END_RE.match(lines[j]):
goto_end_idx = j
if image is not None and goto_end_idx is not None:
blocks.append(
{"target": target, "image": image, "start": i, "end": goto_end_idx}
)
return blocks
def _find_dispatch_lines(lines):
"""All `if "%choice%"=="N" goto TARGET` lines as dicts {idx,num,target,indent}."""
out = []
for i, line in enumerate(lines):
m = _DISPATCH_RE.match(line)
if m:
out.append(
{
"idx": i,
"indent": m.group(1),
"num": int(m.group(2)),
"target": m.group(3),
}
)
return out
def _find_choice_prompt(lines, start):
for i in range(start, len(lines)):
if _CHOICE_PROMPT_RE.match(lines[i]):
return i
return None
def _find_numbered_echo_run(lines, lo, hi):
"""Contiguous run of numbered 'echo N. Label' lines within [lo, hi).
Returns (indices, indent, labels_by_num) or raises ValueError if the numbered
echo lines are not on consecutive source lines (ambiguous - refuse to edit).
"""
matched = []
for i in range(lo, hi):
m = _ECHO_NUM_RE.match(lines[i])
if m:
matched.append((i, m))
if not matched:
raise ValueError("no numbered 'echo N. ...' menu lines found")
idxs = [i for i, _ in matched]
if idxs != list(range(idxs[0], idxs[0] + len(idxs))):
raise ValueError("menu echo lines are not contiguous")
indent = matched[0][1].group(1)
labels = {int(m.group(2)): m.group(3).rstrip() for _, m in matched}
return idxs, indent, labels
def _analyze_boot_menu(lines):
"""Locate every editable region of the image menu. Raises ValueError if the
clearly-delimited structure is missing/ambiguous. Returns a dict of parts."""
menu_idx = _find_label_line(lines, "menu")
if menu_idx is None:
raise ValueError("no :menu label found")
choice_idx = _find_choice_prompt(lines, menu_idx)
if choice_idx is None:
raise ValueError("no 'set /p choice=' prompt found after :menu")
echo_idxs, echo_indent, echo_labels = _find_numbered_echo_run(
lines, menu_idx, choice_idx
)
image_blocks = _find_image_blocks(lines)
if not image_blocks:
raise ValueError("no image action blocks (net use winpeapps ... goto end)")
img_targets = {b["target"] for b in image_blocks}
all_dispatch = _find_dispatch_lines(lines)
image_dispatch = [d for d in all_dispatch if d["target"] in img_targets]
if not image_dispatch:
raise ValueError("no image %choice% dispatch lines found")
d_idxs = [d["idx"] for d in image_dispatch]
if d_idxs != list(range(d_idxs[0], d_idxs[-1] + 1)):
raise ValueError("image %choice% dispatch lines are not contiguous")
blocks_sorted = sorted(image_blocks, key=lambda b: b["start"])
region_start = blocks_sorted[0]["start"]
region_end = blocks_sorted[-1]["end"]
# every line inside the block region must belong to a block span or be blank
covered = set()
for b in blocks_sorted:
covered.update(range(b["start"], b["end"] + 1))
for j in range(region_start, region_end + 1):
if j not in covered and lines[j].strip() != "":
raise ValueError(
"image action blocks are not contiguous (foreign content at "
"line %d)" % (j + 1)
)
return {
"menu_idx": menu_idx,
"choice_idx": choice_idx,
"echo_idxs": echo_idxs,
"echo_indent": echo_indent,
"echo_labels": echo_labels,
"image_blocks": blocks_sorted,
"image_dispatch": image_dispatch,
"all_dispatch": all_dispatch,
"region_start": region_start,
"region_end": region_end,
"dispatch_start": d_idxs[0],
"dispatch_end": d_idxs[-1],
}
def parse_boot_menu(content):
"""content -> list of image-menu items, ordered by menu number.
Each item dict:
num int choice number that routes to this image
label str echo text after 'N. ' (kept verbatim, trailing ws only stripped)
target str goto label of the action block (the ':<target>')
target_image str winpeapps\\<image> the block maps Z: to
Returns [] if the delimited menu region cannot be found (caller uses raw text).
"""
lines = _split_lines(content)
try:
a = _analyze_boot_menu(lines)
except ValueError:
return []
by_target = {b["target"]: b for b in a["image_blocks"]}
items = []
for d in a["image_dispatch"]:
blk = by_target[d["target"]]
items.append(
{
"num": d["num"],
"label": a["echo_labels"].get(d["num"], ""),
"target": d["target"],
"target_image": blk["image"],
}
)
items.sort(key=lambda it: it["num"])
return items
def _synth_block(item, ip, eol_indent=""):
"""Build a fresh per-image action block for a newly added menu item."""
target = item["target"]
image = item.get("target_image") or target
label = item.get("label") or target
return [
":%s" % target,
"echo.",
"echo Starting %s setup..." % label,
r'start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe',
r"for /l %%i in (1,1,2000000) do rem",
r"net use Z: \\%s\winpeapps\%s /user:pxe-upload pxe /persistent:no"
% (ip, image),
"goto end",
]
def apply_boot_menu(content, items):
"""items -> content: regenerate ONLY the menu echo lines, the choice prompt
count, the image %choice% dispatch (renumbered 1..N), and reorder/add/remove
the per-image action blocks. Existing block BODIES are kept byte-for-byte;
only new blocks are synthesised. Everything else is untouched.
items: list of {label, target, target_image} (a 'num' key, if present, is
ignored - order in the list defines the new numbering).
Raises ValueError when the structure is ambiguous OR when renumbering would
desync a secondary `%choice%` router (e.g. the enrollment routing that also
keys off choice numbers). The caller then falls back to the raw editor.
"""
if not items:
raise ValueError("boot menu must have at least one item")
eol = _detect_eol(content)
lines = _split_lines(content)
ip = _detect_server_ip(content) or "172.16.9.1"
a = _analyze_boot_menu(lines)
old_num_by_target = {d["target"]: d["num"] for d in a["image_dispatch"]}
old_target_by_num = {d["num"]: d["target"] for d in a["image_dispatch"]}
new_num_by_target = {it["target"]: i + 1 for i, it in enumerate(items)}
# Safety: a secondary %choice% router (not one of the image blocks) that
# references an image number whose meaning is about to change is a desync.
image_dispatch_idxs = {d["idx"] for d in a["image_dispatch"]}
for d in a["all_dispatch"]:
if d["idx"] in image_dispatch_idxs:
continue
n = d["num"]
if n in old_target_by_num: # this number currently selects an image
tgt = old_target_by_num[n]
if tgt not in new_num_by_target or new_num_by_target[tgt] != n:
raise ValueError(
"refusing to renumber: secondary %%choice%% routing at line "
"%d references image choice %d whose number would change"
% (d["idx"] + 1, n)
)
blocks_by_target = {
b["target"]: lines[b["start"]:b["end"] + 1] for b in a["image_blocks"]
}
# --- build replacement lines for each region ---
new_echo = [
"%secho %d. %s" % (a["echo_indent"], i + 1, it.get("label") or it["target"])
for i, it in enumerate(items)
]
disp_indent = a["image_dispatch"][0]["indent"]
new_dispatch = [
'%sif "%%choice%%"=="%d" goto %s' % (disp_indent, i + 1, it["target"])
for i, it in enumerate(items)
]
cm = _CHOICE_PROMPT_RE.match(lines[a["choice_idx"]])
new_choice = "%s%d%s" % (cm.group(1), len(items), cm.group(3))
new_region = []
for i, it in enumerate(items):
if i > 0:
new_region.append("") # blank line between blocks, matching style
if it["target"] in blocks_by_target:
new_region.extend(blocks_by_target[it["target"]])
else:
new_region.extend(_synth_block(it, ip))
# Apply splices high-index-first so earlier indices stay valid.
splices = [
(a["region_start"], a["region_end"], new_region),
(a["dispatch_start"], a["dispatch_end"], new_dispatch),
(a["choice_idx"], a["choice_idx"], [new_choice]),
(a["echo_idxs"][0], a["echo_idxs"][-1], new_echo),
]
for start, end, repl in sorted(splices, key=lambda s: s[0], reverse=True):
lines[start:end + 1] = repl
return eol.join(lines)
# ---------------------------------------------------------------------------
# Feature 3: lint
# ---------------------------------------------------------------------------
def _strip_for_parens(line):
"""Neutralise a line for paren counting: drop comments, quoted spans, and
caret-escaped chars (so `^>NUL`, `echo ... ^(` etc. do not count)."""
s = line.lstrip()
low = s.lower()
if low.startswith("rem ") or low == "rem" or s.startswith("::"):
return ""
# remove escaped pairs first, then any caret-escaped char
s = s.replace("^^", "")
s = re.sub(r"\^.", "", s)
# drop double-quoted spans
s = re.sub(r'"[^"]*"', "", s)
return s
def lint_startnet(content):
"""Static checks over startnet.cmd. Advisory - never blocks a save.
Returns {"errors": [...], "warnings": [...]} where each entry is
{"line": int|None, "message": str}. Errors: goto with no matching label.
Warnings: lone-LF (non-CRLF) lines, unbalanced parentheses.
"""
errors = []
warnings = []
lines = _split_lines(content)
labels = set()
for line in lines:
m = _LABEL_RE.match(line)
if m:
labels.add(m.group(1).lower())
for i, line in enumerate(lines):
# A `goto` inside an `echo` line is emitted text (e.g. the enroll.cmd
# here-doc), not a jump - skip it to avoid false positives.
if line.lstrip().lower().startswith("echo"):
continue
for m in re.finditer(
r"goto\s+:?([A-Za-z0-9_][A-Za-z0-9_\-]*)", line, re.IGNORECASE
):
target = m.group(1).lower()
if target == "eof":
continue # goto :eof is a cmd.exe builtin
if target not in labels:
errors.append(
{
"line": i + 1,
"message": "goto %s has no matching :%s label"
% (m.group(1), m.group(1)),
}
)
# CRLF check: count lone LFs (a \n not preceded by \r)
crlf = content.count("\r\n")
lf = content.count("\n")
lone = lf - crlf
if lone > 0:
first = None
idx = content.find("\n")
ln = 1
while idx != -1:
if idx == 0 or content[idx - 1] != "\r":
first = ln
break
idx = content.find("\n", idx + 1)
ln += 1
warnings.append(
{
"line": first,
"message": "%d line(s) are not CRLF-terminated; WinPE batch "
"expects CRLF" % lone,
}
)
# Parenthesis balance (best-effort, comments/quotes/carets removed)
delta = 0
for line in lines:
cleaned = _strip_for_parens(line)
delta += cleaned.count("(") - cleaned.count(")")
if delta != 0:
warnings.append(
{
"line": None,
"message": "parentheses look unbalanced (delta %+d) - check "
"if(...) blocks" % delta,
}
)
return {"errors": errors, "warnings": warnings}
# ---------------------------------------------------------------------------
# Feature 4: snapshots + diff
# ---------------------------------------------------------------------------
def _ensure_backups_dir():
os.makedirs(BACKUPS_DIR, exist_ok=True)
def _snapshot_meta(path):
st = os.stat(path)
base = os.path.basename(path)
note = ""
note_path = path + ".note"
if os.path.isfile(note_path):
try:
with open(note_path, "r", encoding="utf-8", errors="replace") as fh:
note = fh.read().strip()
except Exception:
note = ""
return {
"id": base,
"path": path,
"mtime": st.st_mtime,
"timestamp": datetime.datetime.fromtimestamp(st.st_mtime).strftime(
"%Y-%m-%d %H:%M:%S"
),
"size": st.st_size,
"note": note,
}
def save_snapshot(content, note=""):
"""Write content verbatim to a timestamped file under BACKUPS_DIR.
Snapshots the bytes exactly (no line-ending translation) so a restore
round-trips faithfully. Returns the snapshot metadata dict (see
_snapshot_meta). Call this with the PRIOR startnet before overwriting it.
"""
_ensure_backups_dir()
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
base = "startnet-%s.cmd" % ts
path = os.path.join(BACKUPS_DIR, base)
n = 1
while os.path.exists(path):
base = "startnet-%s-%d.cmd" % (ts, n)
path = os.path.join(BACKUPS_DIR, base)
n += 1
with open(path, "w", encoding="utf-8", newline="") as fh:
fh.write(content)
if note:
with open(path + ".note", "w", encoding="utf-8", newline="") as fh:
fh.write(note)
return _snapshot_meta(path)
def list_snapshots():
"""Return snapshot metadata dicts, newest first."""
_ensure_backups_dir()
metas = []
for path in glob.glob(os.path.join(BACKUPS_DIR, "startnet-*.cmd")):
if path.endswith(".note"):
continue
try:
metas.append(_snapshot_meta(path))
except OSError:
continue
metas.sort(key=lambda m: m["mtime"], reverse=True)
return metas
def read_snapshot(snapshot_id):
"""Return a snapshot's text by id (its filename), or None if not found.
Rejects path traversal: the id must be a bare basename under BACKUPS_DIR.
"""
if not snapshot_id or os.path.basename(snapshot_id) != snapshot_id:
return None
if ".." in snapshot_id or "/" in snapshot_id or "\\" in snapshot_id:
return None
path = os.path.join(BACKUPS_DIR, snapshot_id)
if not os.path.isfile(path):
return None
# newline="" so the stored CRLF bytes come back verbatim (faithful restore).
with open(path, "r", encoding="utf-8", errors="replace", newline="") as fh:
return fh.read()
def unified_diff(a, b, fromfile="current", tofile="new"):
"""Unified diff between two startnet texts, as a single string ('' if equal).
Line endings are normalised for comparison so a pure CRLF/LF difference does
not produce a wall of noise; content differences are what surface.
"""
a_lines = _split_lines(a)
b_lines = _split_lines(b)
return "\n".join(
difflib.unified_diff(
a_lines, b_lines, fromfile=fromfile, tofile=tofile, lineterm=""
)
)

View File

@@ -3,47 +3,140 @@
{% block extra_head %} {% block extra_head %}
<style> <style>
.cmd-editor { /* ---- shared editor typography (keep pre + textarea metrics identical) ---- */
font-family: 'Consolas', 'Courier New', monospace; .stn-mono {
font-size: 0.9rem; font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
min-height: 600px; font-size: 13px;
height: 70vh; line-height: 21px;
resize: vertical; letter-spacing: normal;
width: 100%; tab-size: 4;
display: block; -moz-tab-size: 4;
background-color: #1e1e1e; }
color: #d4d4d4;
/* ---- Raw tab: gutter + highlight overlay + textarea ---- */
.raw-editor-wrap {
display: flex;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 0.25rem; border-radius: 0.25rem;
padding: 1rem; background: #1e1e1e;
tab-size: 4; overflow: hidden;
height: 62vh;
min-height: 460px;
}
.raw-gutter {
flex: 0 0 auto;
width: 3.25rem;
padding: 1rem 0.5rem 1rem 0;
text-align: right;
color: #6a737d;
background: #191919;
border-right: 1px solid #2a2a2a;
overflow: hidden;
user-select: none;
white-space: pre; white-space: pre;
}
.raw-code {
position: relative;
flex: 1 1 auto;
overflow: hidden;
}
.raw-highlight,
.raw-textarea {
margin: 0;
padding: 1rem;
border: 0;
white-space: pre;
word-wrap: normal;
overflow: auto;
width: 100%;
height: 100%;
}
.raw-highlight {
position: absolute;
inset: 0;
color: #d4d4d4;
background: transparent;
pointer-events: none;
overflow: hidden;
}
.raw-textarea {
position: relative;
display: block;
resize: none;
background: transparent;
color: #d4d4d4; /* visible if JS/overlay never activates (fallback) */
caret-color: #d4d4d4;
}
/* only once the overlay is proven live do we hide the textarea's own glyphs */
.raw-editor-wrap.overlay-live .raw-textarea { color: transparent; }
.raw-textarea:focus { outline: none; }
.raw-textarea::selection { background: rgba(65, 129, 255, 0.35); }
/* batch token colors (VS Code-ish dark) */
.tok-comment { color: #6a9955; font-style: italic; }
.tok-label { color: #dcdcaa; font-weight: 600; }
.tok-keyword { color: #569cd6; }
.tok-var { color: #4ec9b0; }
.tok-string { color: #ce9178; }
/* ---- lint panel ---- */
.lint-list { list-style: none; margin: 0; padding: 0; }
.lint-list li {
display: flex;
gap: 0.5rem;
align-items: baseline;
padding: 0.4rem 0.2rem;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.lint-list li:last-child { border-bottom: 0; }
.lint-line {
flex: 0 0 auto;
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 11px;
color: var(--text-light);
min-width: 3rem;
}
/* ---- diff viewer ---- */
.diff-view {
margin: 0;
padding: 1rem;
background: #1e1e1e;
color: #d4d4d4;
border-radius: 0.25rem;
max-height: 55vh;
overflow: auto;
white-space: pre;
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 12.5px;
line-height: 1.5; line-height: 1.5;
} }
.cmd-editor:focus { .diff-add { color: #6a9955; }
outline: none; .diff-del { color: #f14c4c; }
border-color: var(--primary); .diff-hunk { color: #569cd6; }
box-shadow: 0 0 0 0.15rem rgba(65, 129, 255, 0.25); .diff-meta { color: #808080; }
.diff-identical {
padding: 2rem;
text-align: center;
color: var(--success);
} }
/* ---- misc ---- */
.wim-info dl { margin: 0; } .wim-info dl { margin: 0; }
.wim-info dt { .wim-info dt {
font-weight: 600; font-weight: 600; color: var(--text-light);
color: var(--text-light); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.wim-info dd {
margin: 0 0 0.65rem 0;
color: var(--text);
} }
.wim-info dd { margin: 0 0 0.65rem 0; color: var(--text); }
.cmd-ref-item { margin-bottom: 0.85rem; } .cmd-ref-item { margin-bottom: 0.85rem; }
.cmd-ref-item code { .cmd-ref-item code { display: block; margin-bottom: 0.2rem; word-break: break-all; }
display: block;
margin-bottom: 0.2rem;
word-break: break-all;
}
.cmd-ref-item small { color: var(--text-light); } .cmd-ref-item small { color: var(--text-light); }
.tab-content-pad { padding-top: 1.25rem; }
.menu-num-cell { font-variant-numeric: tabular-nums; color: var(--text-light); width: 2.5rem; }
.menu-move-btns { display: inline-flex; gap: 0.2rem; }
.field-hint { font-size: 12px; color: var(--text-light); margin-top: 0.25rem; }
.stn-modal { max-width: min(900px, 96vw); }
</style> </style>
{% endblock %} {% endblock %}
@@ -52,9 +145,7 @@
<h1>startnet.cmd Editor</h1> <h1>startnet.cmd Editor</h1>
{% if wim_exists %} {% if wim_exists %}
<div class="header-actions"> <div class="header-actions">
<button type="submit" form="startnetForm" class="pxe-btn pxe-btn-primary"> <span class="badge badge-secondary"><i class="bi bi-hdd"></i> boot.wim</span>
<i class="bi bi-save"></i> Save to boot.wim
</button>
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -64,88 +155,594 @@
<strong>boot.wim not found</strong> at <code>{{ wim_path }}</code>. <strong>boot.wim not found</strong> at <code>{{ wim_path }}</code>.
Run the PXE server setup playbook and import WinPE boot files first. Run the PXE server setup playbook and import WinPE boot files first.
</div> </div>
{% if snapshots %}
<div class="section-card">
<div class="section-title">Snapshots</div>
<p class="text-light">boot.wim is missing, but prior startnet.cmd snapshots are still on disk. Restore is unavailable until the WIM is present.</p>
</div>
{% endif %}
{% else %} {% else %}
<div class="row"> <ul class="nav nav-tabs editor-tabs" id="stnTabs" role="tablist">
<div class="col-lg-9"> <li class="nav-item" role="presentation">
<div class="pxe-card"> <button class="nav-link active" id="tab-settings-btn" data-bs-toggle="tab" data-bs-target="#tab-settings" type="button" role="tab">
<div class="d-flex justify-content-between align-items-center mb-3"> <i class="bi bi-sliders"></i> Settings
<span class="mono"><i class="bi bi-terminal"></i> Windows\System32\startnet.cmd</span> </button>
<span class="badge badge-secondary">boot.wim</span> </li>
</div> <li class="nav-item" role="presentation">
<form action="{{ url_for('startnet_save') }}" method="post" id="startnetForm"> <button class="nav-link" id="tab-menu-btn" data-bs-toggle="tab" data-bs-target="#tab-menu" type="button" role="tab">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <i class="bi bi-list-ol"></i> Boot Menu
<textarea name="content" class="cmd-editor" id="cmdEditor" </button>
spellcheck="false">{{ content }}</textarea> </li>
</form> <li class="nav-item" role="presentation">
<div class="d-flex justify-content-between align-items-center mt-3"> <button class="nav-link" id="tab-raw-btn" data-bs-toggle="tab" data-bs-target="#tab-raw" type="button" role="tab">
<small class="text-muted"> <i class="bi bi-code-slash"></i> Raw
Editing the startnet.cmd inside <code>{{ wim_path }}</code> {% if lint.errors %}<span class="badge badge-danger ms-1">{{ lint.errors|length }}</span>
</small> {% elif lint.warnings %}<span class="badge badge-warning ms-1">{{ lint.warnings|length }}</span>{% endif %}
<button type="submit" form="startnetForm" class="pxe-btn pxe-btn-primary"> </button>
<i class="bi bi-save"></i> Save to boot.wim </li>
</button> <li class="nav-item" role="presentation">
</div> <button class="nav-link" id="tab-history-btn" data-bs-toggle="tab" data-bs-target="#tab-history" type="button" role="tab">
</div> <i class="bi bi-clock-history"></i> History
{% if snapshots %}<span class="badge badge-secondary ms-1">{{ snapshots|length }}</span>{% endif %}
</button>
</li>
</ul>
<div class="section-card"> <div class="tab-content tab-content-pad">
<div class="section-title">Common startnet.cmd Commands</div>
<div class="row"> <!-- =============================== SETTINGS =============================== -->
<div class="col-md-6"> <div class="tab-pane fade show active" id="tab-settings" role="tabpanel">
<div class="cmd-ref-item"> <div class="row">
<code>wpeinit</code> <div class="col-lg-7">
<small>Initialize WinPE networking</small> <div class="pxe-card">
</div> <h3 class="pxe-card-title">Boot Settings</h3>
<div class="cmd-ref-item"> <p class="text-light" style="font-size:13px;">
<code>net use Z: \\172.16.9.1\winpeapps</code> These edit only their targeted lines in startnet.cmd. Leave a field
<small>Map Samba share for deployment</small> <strong>blank to keep</strong> the current value. Everything round-trips
</div> through the same boot.wim as the Raw tab.
</p>
<form action="{{ url_for('startnet_save_settings') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-field">
<label for="setServerIp">PXE / Deployment Server IP</label>
<input type="text" class="form-control stn-mono" id="setServerIp" name="server_ip"
value="{{ settings.server_ip if settings.server_ip is not none else '' }}"
placeholder="e.g. 172.16.9.1">
<div class="field-hint">Address startnet.cmd maps the winpeapps share from.</div>
</div>
<div class="form-field">
<label for="setTimeout">Menu Timeout (seconds)</label>
<input type="number" min="0" class="form-control" id="setTimeout" name="menu_timeout"
value="{{ settings.menu_timeout if settings.menu_timeout is not none else '' }}"
placeholder="e.g. 30">
<div class="field-hint">Whole seconds before the default menu entry auto-selects.</div>
</div>
<div class="form-field">
<label for="setDefault">Default Menu Entry</label>
<input type="text" class="form-control" id="setDefault" name="menu_default"
value="{{ settings.menu_default if settings.menu_default is not none else '' }}"
placeholder="e.g. 1">
<div class="field-hint">The entry chosen when the timeout elapses.</div>
</div>
<button type="submit" class="pxe-btn pxe-btn-primary">
<i class="bi bi-save"></i> Apply &amp; Save to boot.wim
</button>
</form>
</div> </div>
<div class="col-md-6"> </div>
<div class="cmd-ref-item"> <div class="col-lg-5">
<code>wpeutil WaitForNetwork</code> <div class="section-card wim-info">
<small>Wait for network to be ready</small> <div class="section-title">WIM Info</div>
</div> <dl>
<div class="cmd-ref-item"> {% for key, val in wim_info.items() %}
<code>Z:\gea-standard\Deploy\Tools\deploy.cmd</code> {% if key in ['Image Count', 'Compression', 'Total Bytes', 'Image Name', 'Image Description'] %}
<small>Launch deployment script</small> <dt>{{ key }}</dt>
</div> <dd>{{ val }}</dd>
{% endif %}
{% endfor %}
{% if not wim_info %}
<p class="text-light mb-0">Could not read WIM info.</p>
{% endif %}
</dl>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-lg-3"> <!-- =============================== BOOT MENU ============================== -->
<div class="section-card wim-info"> <div class="tab-pane fade" id="tab-menu" role="tabpanel">
<div class="section-title">WIM Info</div> <div class="pxe-card">
<dl> <div class="d-flex justify-content-between align-items-center mb-2">
{% for key, val in wim_info.items() %} <h3 class="pxe-card-title mb-0">Boot Menu Entries</h3>
{% if key in ['Image Count', 'Compression', 'Total Bytes', 'Image Name', 'Image Description'] %} <button type="button" class="pxe-btn pxe-btn-secondary pxe-btn-sm" id="menuAddBtn">
<dt>{{ key }}</dt> <i class="bi bi-plus-lg"></i> Add Entry
<dd>{{ val }}</dd> </button>
{% endif %} </div>
{% endfor %} <div class="alert alert-info" style="font-size:13px;">
{% if not wim_info %} <i class="bi bi-info-circle"></i>
<p class="text-muted mb-0">Could not read WIM info.</p> <strong>Appending</strong> a new image at the end always works. Reordering or
{% endif %} removing an entry whose number the enrollment <code>%choice%</code> router
</dl> cross-references may be <strong>refused by the server</strong> to keep numbering
in sync - if so, the raw text is left untouched and the reason is flashed.
</div>
<form action="{{ url_for('startnet_save_menu') }}" method="post" id="menuForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="items" id="menuItemsData" value="[]">
<div class="table-container">
<table class="data-table" id="menuTable" style="white-space:normal;">
<thead>
<tr>
<th style="width:2.5rem;">#</th>
<th>Label</th>
<th>Target</th>
<th>Target Image</th>
<th style="width:9rem;">Actions</th>
</tr>
</thead>
<tbody id="menuBody"><!-- rows injected by JS --></tbody>
</table>
</div>
<div id="menuEmpty" class="text-light" style="padding:1rem 0; display:none;">
No menu entries parsed. Use the Raw tab, or add an entry above.
</div>
<div class="mt-3">
<button type="submit" class="pxe-btn pxe-btn-primary">
<i class="bi bi-save"></i> Save Menu to boot.wim
</button>
</div>
</form>
</div>
</div>
<!-- ================================= RAW ================================= -->
<div class="tab-pane fade" id="tab-raw" role="tabpanel">
<div class="row">
<div class="col-lg-9">
<div class="pxe-card">
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="mono"><i class="bi bi-terminal"></i> Windows\System32\startnet.cmd</span>
<div class="d-flex gap-2">
<button type="button" class="pxe-btn pxe-btn-ghost pxe-btn-sm" id="rawDiffBtn">
<i class="bi bi-file-earmark-diff"></i> Diff vs current
</button>
<button type="submit" form="startnetForm" class="pxe-btn pxe-btn-primary pxe-btn-sm">
<i class="bi bi-save"></i> Save to boot.wim
</button>
</div>
</div>
<form action="{{ url_for('startnet_save') }}" method="post" id="startnetForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="raw-editor-wrap" id="rawWrap">
<div class="raw-gutter stn-mono" id="rawGutter" aria-hidden="true">1</div>
<div class="raw-code">
<pre class="raw-highlight stn-mono" id="rawHighlight" aria-hidden="true"></pre>
<textarea name="content" class="raw-textarea stn-mono" id="cmdEditor"
spellcheck="false" autocomplete="off" autocapitalize="off"
wrap="off">{{ content }}</textarea>
</div>
</div>
</form>
<div class="d-flex justify-content-between align-items-center mt-3">
<small class="text-light">Editing startnet.cmd inside <code>{{ wim_path }}</code></small>
<button type="submit" form="startnetForm" class="pxe-btn pxe-btn-primary">
<i class="bi bi-save"></i> Save to boot.wim
</button>
</div>
</div>
<div class="section-card">
<div class="section-title">Common startnet.cmd Commands</div>
<div class="row">
<div class="col-md-6">
<div class="cmd-ref-item">
<code>wpeinit</code><small>Initialize WinPE networking</small>
</div>
<div class="cmd-ref-item">
<code>net use Z: \\172.16.9.1\winpeapps</code><small>Map Samba share for deployment</small>
</div>
</div>
<div class="col-md-6">
<div class="cmd-ref-item">
<code>wpeutil WaitForNetwork</code><small>Wait for network to be ready</small>
</div>
<div class="cmd-ref-item">
<code>Z:\gea-standard\Deploy\Tools\deploy.cmd</code><small>Launch deployment script</small>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-3">
<div class="section-card">
<div class="section-title">
Lint
{% if lint.errors %}<span class="badge badge-danger">{{ lint.errors|length }} err</span>{% endif %}
{% if lint.warnings %}<span class="badge badge-warning">{{ lint.warnings|length }} warn</span>{% endif %}
</div>
{% if not lint.errors and not lint.warnings %}
<p class="text-light mb-0"><i class="bi bi-check-circle text-success"></i> No issues found.</p>
{% else %}
<ul class="lint-list">
{% for e in lint.errors %}
<li>
<span class="lint-line">{{ ('L' ~ e.line) if e.line is not none else '--' }}</span>
<span><i class="bi bi-x-octagon text-danger"></i> {{ e.message }}</span>
</li>
{% endfor %}
{% for w in lint.warnings %}
<li>
<span class="lint-line">{{ ('L' ~ w.line) if w.line is not none else '--' }}</span>
<span><i class="bi bi-exclamation-triangle text-warning"></i> {{ w.message }}</span>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
<div class="section-card wim-info">
<div class="section-title">WIM Info</div>
<dl>
{% for key, val in wim_info.items() %}
{% if key in ['Image Count', 'Compression', 'Total Bytes', 'Image Name', 'Image Description'] %}
<dt>{{ key }}</dt>
<dd>{{ val }}</dd>
{% endif %}
{% endfor %}
{% if not wim_info %}
<p class="text-light mb-0">Could not read WIM info.</p>
{% endif %}
</dl>
</div>
</div>
</div>
</div>
<!-- =============================== HISTORY =============================== -->
<div class="tab-pane fade" id="tab-history" role="tabpanel">
<div class="pxe-card">
<h3 class="pxe-card-title">Snapshots</h3>
<p class="text-light" style="font-size:13px;">
Every save snapshots the prior startnet.cmd first. Diff a snapshot against the
current boot.wim, or restore it (restoring also snapshots the current copy first).
</p>
{% if not snapshots %}
<p class="text-light mb-0">No snapshots yet.</p>
{% else %}
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Size</th>
<th>Note</th>
<th style="width:14rem;">Actions</th>
</tr>
</thead>
<tbody>
{% for s in snapshots %}
<tr>
<td class="mono">{{ s.timestamp }}</td>
<td>{{ '%.1f'|format(s.size / 1024) }} KB</td>
<td>{{ s.note or '-' }}</td>
<td class="actions">
<button type="button" class="pxe-btn pxe-btn-ghost pxe-btn-sm snap-diff-btn"
data-snapshot-id="{{ s.id }}">
<i class="bi bi-file-earmark-diff"></i> Diff
</button>
<form action="{{ url_for('startnet_restore_snapshot') }}" method="post" class="d-inline"
onsubmit="return confirm('Restore this snapshot to boot.wim? The current startnet.cmd is snapshotted first.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="snapshot_id" value="{{ s.id }}">
<button type="submit" class="pxe-btn pxe-btn-secondary pxe-btn-sm">
<i class="bi bi-arrow-counterclockwise"></i> Restore
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
</div>
<!-- ============================= DIFF MODAL ============================= -->
<div class="modal fade" id="diffModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered stn-modal">
<div class="pxe-modal">
<div class="modal-header">
<h3 id="diffModalTitle">Diff</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="diffModalBody"><div class="text-light" style="padding:1rem;">Loading...</div></div>
</div>
<div class="modal-footer">
<button type="button" class="pxe-btn pxe-btn-ghost" data-bs-dismiss="modal">Close</button>
</div>
</div> </div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}
{% if wim_exists %}
<script> <script>
// Tab key inserts a tab in the editor instead of moving focus (function () {
document.getElementById('cmdEditor')?.addEventListener('keydown', function(e) { "use strict";
if (e.key === 'Tab') {
e.preventDefault(); var CSRF = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
var start = this.selectionStart; var DIFF_URL = "{{ url_for('startnet_diff') }}";
var end = this.selectionEnd;
this.value = this.value.substring(0, start) + '\t' + this.value.substring(end); // ------------------------------------------------------------------ escape
this.selectionStart = this.selectionEnd = start + 1; function esc(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
} }
});
// ---------------------------------------------------- batch highlighter
// Per-line so comments/labels never leak across lines. Operates on ESCAPED
// text and only ever wraps whole matched tokens, so it cannot corrupt input.
var KW = /\b(echo|set|setlocal|endlocal|if|else|for|in|do|goto|call|exit|pause|cls|start|del|copy|xcopy|robocopy|md|mkdir|rd|rmdir|cd|pushd|popd|choice|timeout|ping|reg|net|use|wpeinit|wpeutil|ver|title|color|shutdown|wmic|diskpart|dism|bcdboot|drvload|not|exist|defined|errorlevel|equ|neq|gtr|geq|lss|leq)\b/gi;
function hlLine(raw) {
var line = esc(raw);
var trimmed = line.replace(/^\s+/, '');
// whole-line comment (rem, ::, @rem)
if (/^(@?rem\b|::)/i.test(trimmed)) {
return '<span class="tok-comment">' + line + '</span>';
}
// label line ( :name )
if (/^:[^:\s]/.test(trimmed)) {
return '<span class="tok-label">' + line + '</span>';
}
// token pass: strings first, then %vars%, then leading keyword-ish words
line = line.replace(/&quot;[^&]*&quot;|"[^"]*"/g, function (m) {
return '<span class="tok-string">' + m + '</span>';
});
line = line.replace(/%[^%\s]+%|%%?[a-zA-Z]\b|![^!\s]+!/g, function (m) {
return '<span class="tok-var">' + m + '</span>';
});
line = line.replace(KW, function (m) {
return '<span class="tok-keyword">' + m + '</span>';
});
return line;
}
var wrap = document.getElementById('rawWrap');
var ta = document.getElementById('cmdEditor');
var hl = document.getElementById('rawHighlight');
var gutter = document.getElementById('rawGutter');
function renderHighlight() {
if (!ta || !hl) return;
var text = ta.value;
var lines = text.split('\n');
var out = [];
for (var i = 0; i < lines.length; i++) out.push(hlLine(lines[i]));
// trailing newline guard so the overlay height tracks the textarea
hl.innerHTML = out.join('\n') + '\n';
}
function renderGutter() {
if (!ta || !gutter) return;
var n = ta.value.split('\n').length;
var buf = [];
for (var i = 1; i <= n; i++) buf.push(i);
gutter.textContent = buf.join('\n');
}
function syncScroll() {
if (hl) { hl.scrollTop = ta.scrollTop; hl.scrollLeft = ta.scrollLeft; }
if (gutter) { gutter.scrollTop = ta.scrollTop; }
}
if (ta) {
try {
renderHighlight();
renderGutter();
syncScroll();
// Only hide the textarea glyphs once the overlay is confirmed built.
if (wrap && hl.innerHTML) wrap.classList.add('overlay-live');
ta.addEventListener('input', function () {
renderHighlight();
renderGutter();
syncScroll();
});
ta.addEventListener('scroll', syncScroll);
// Tab key inserts a real tab instead of moving focus.
ta.addEventListener('keydown', function (e) {
if (e.key === 'Tab') {
e.preventDefault();
var start = this.selectionStart, end = this.selectionEnd;
this.value = this.value.substring(0, start) + '\t' + this.value.substring(end);
this.selectionStart = this.selectionEnd = start + 1;
renderHighlight(); renderGutter(); syncScroll();
}
});
} catch (err) {
// Highlight is a nicety; on any failure fall back to the plain textarea.
if (wrap) wrap.classList.remove('overlay-live');
}
}
// ------------------------------------------------------------- diff modal
var diffModalEl = document.getElementById('diffModal');
var diffModal = diffModalEl && window.bootstrap ? new bootstrap.Modal(diffModalEl) : null;
function renderDiff(diffText) {
var body = document.getElementById('diffModalBody');
if (!diffText) {
body.innerHTML = '<div class="diff-identical"><i class="bi bi-check-circle"></i> ' +
'No differences - identical to the current boot.wim.</div>';
return;
}
var lines = diffText.split('\n');
var html = '';
for (var i = 0; i < lines.length; i++) {
var ln = lines[i], cls = '';
if (/^\+\+\+|^---/.test(ln)) cls = 'diff-meta';
else if (ln.charAt(0) === '@') cls = 'diff-hunk';
else if (ln.charAt(0) === '+') cls = 'diff-add';
else if (ln.charAt(0) === '-') cls = 'diff-del';
html += '<span class="' + cls + '">' + esc(ln) + '</span>\n';
}
body.innerHTML = '<pre class="diff-view">' + html + '</pre>';
}
function openDiff(title, payload) {
var body = document.getElementById('diffModalBody');
document.getElementById('diffModalTitle').textContent = title;
body.innerHTML = '<div class="text-light" style="padding:1rem;">Loading diff...</div>';
if (diffModal) diffModal.show();
fetch(DIFF_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF },
body: JSON.stringify(payload)
}).then(function (r) {
return r.json().then(function (j) { return { ok: r.ok, j: j }; });
}).then(function (res) {
if (!res.ok) {
body.innerHTML = '<div class="alert alert-danger mb-0">' +
esc(res.j.error || 'Diff failed.') + '</div>';
return;
}
renderDiff(res.j.diff || '');
}).catch(function () {
body.innerHTML = '<div class="alert alert-danger mb-0">Could not reach the diff endpoint.</div>';
});
}
var rawDiffBtn = document.getElementById('rawDiffBtn');
if (rawDiffBtn) {
rawDiffBtn.addEventListener('click', function () {
openDiff('Pending edit vs current boot.wim', { content: ta.value });
});
}
document.querySelectorAll('.snap-diff-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var id = btn.getAttribute('data-snapshot-id');
openDiff('Snapshot ' + id + ' vs current', { snapshot_id: id });
});
});
// -------------------------------------------------------- boot-menu editor
var MENU = {{ boot_menu | tojson }};
var IMAGE_TYPES = {{ image_types | tojson }};
var body = document.getElementById('menuBody');
var emptyMsg = document.getElementById('menuEmpty');
var form = document.getElementById('menuForm');
var hidden = document.getElementById('menuItemsData');
function optionsFor(selected) {
var opts = '';
var found = false;
for (var i = 0; i < IMAGE_TYPES.length; i++) {
var v = IMAGE_TYPES[i];
if (v === selected) found = true;
opts += '<option value="' + esc(v) + '"' + (v === selected ? ' selected' : '') + '>' + esc(v) + '</option>';
}
// preserve an existing target_image that is not in the current image list
if (selected && !found) {
opts = '<option value="' + esc(selected) + '" selected>' + esc(selected) + ' (unlisted)</option>' + opts;
}
opts = '<option value="">-- none --</option>' + opts;
return opts;
}
function rowHtml(item, idx, total) {
return '' +
'<td class="menu-num-cell">' + (idx + 1) + '</td>' +
'<td><input type="text" class="form-control form-control-sm m-label" value="' + esc(item.label || '') + '" placeholder="Menu label"></td>' +
'<td><input type="text" class="form-control form-control-sm m-target" value="' + esc(item.target || '') + '" placeholder="Target (goto/call)"></td>' +
'<td><select class="form-select form-control-sm m-image">' + optionsFor(item.target_image || '') + '</select></td>' +
'<td class="actions">' +
'<span class="menu-move-btns">' +
'<button type="button" class="pxe-btn pxe-btn-ghost pxe-btn-sm m-up" ' + (idx === 0 ? 'disabled' : '') + ' title="Move up"><i class="bi bi-arrow-up"></i></button>' +
'<button type="button" class="pxe-btn pxe-btn-ghost pxe-btn-sm m-down" ' + (idx === total - 1 ? 'disabled' : '') + ' title="Move down"><i class="bi bi-arrow-down"></i></button>' +
'<button type="button" class="pxe-btn pxe-btn-ghost pxe-btn-sm m-del" title="Remove"><i class="bi bi-trash text-danger"></i></button>' +
'</span>' +
'</td>';
}
// Read the live DOM back into an array (so in-progress typing is captured).
function collect() {
var items = [];
body.querySelectorAll('tr').forEach(function (tr) {
items.push({
label: (tr.querySelector('.m-label') || {}).value || '',
target: (tr.querySelector('.m-target') || {}).value || '',
target_image: (tr.querySelector('.m-image') || {}).value || ''
});
});
return items;
}
function render(items) {
body.innerHTML = '';
for (var i = 0; i < items.length; i++) {
var tr = document.createElement('tr');
tr.innerHTML = rowHtml(items[i], i, items.length);
body.appendChild(tr);
}
if (emptyMsg) emptyMsg.style.display = items.length ? 'none' : 'block';
}
if (body) {
render(MENU.map(function (m) {
return { label: m.label, target: m.target, target_image: m.target_image };
}));
body.addEventListener('click', function (e) {
var btn = e.target.closest('button');
if (!btn) return;
var tr = btn.closest('tr');
var rows = Array.prototype.slice.call(body.querySelectorAll('tr'));
var idx = rows.indexOf(tr);
var items = collect();
if (btn.classList.contains('m-up') && idx > 0) {
var t = items[idx - 1]; items[idx - 1] = items[idx]; items[idx] = t;
render(items);
} else if (btn.classList.contains('m-down') && idx < items.length - 1) {
var d = items[idx + 1]; items[idx + 1] = items[idx]; items[idx] = d;
render(items);
} else if (btn.classList.contains('m-del')) {
items.splice(idx, 1);
render(items);
}
});
var addBtn = document.getElementById('menuAddBtn');
if (addBtn) {
addBtn.addEventListener('click', function () {
var items = collect();
items.push({ label: '', target: '', target_image: '' });
render(items);
});
}
if (form) {
form.addEventListener('submit', function () {
hidden.value = JSON.stringify(collect());
});
}
}
})();
</script> </script>
{% endif %}
{% endblock %} {% endblock %}