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)
#
# 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")
def startnet_editor():
import subprocess
wim_exists = os.path.isfile(config.BOOT_WIM)
content = ""
wim_info = {}
settings = {"server_ip": None, "menu_timeout": None, "menu_default": None}
boot_menu = []
lint = {"errors": [], "warnings": []}
if wim_exists:
content = wim.extract_startnet(config.BOOT_WIM) or ""
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(":")
wim_info[key.strip()] = val.strip()
except Exception:
pass
content = _startnet_current()
wim_info = _startnet_wiminfo()
settings = wim.parse_settings(content)
boot_menu = wim.parse_boot_menu(content)
lint = wim.lint_startnet(content)
snapshots = wim.list_snapshots()
return render_template(
"startnet_editor.html",
@@ -962,6 +1022,10 @@ def startnet_editor():
wim_path=config.BOOT_WIM,
content=content,
wim_info=wim_info,
settings=settings,
boot_menu=boot_menu,
lint=lint,
snapshots=snapshots,
image_types=config.IMAGE_TYPES,
friendly_names=config.FRIENDLY_NAMES,
)
@@ -974,15 +1038,157 @@ def startnet_save():
return redirect(url_for("startnet_editor"))
content = request.form.get("content", "")
ok, err = wim.update_startnet(config.BOOT_WIM, content)
if ok:
audit("STARTNET_SAVE", "boot.wim updated")
prior = _startnet_current()
if _startnet_save_content(content, prior, "STARTNET_SAVE", "boot.wim updated (raw)"):
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"))
@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
# ---------------------------------------------------------------------------

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
that runs when WinPE boots from PXE.
Two layers live here:
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 re
import shutil
import subprocess
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):
@@ -22,7 +48,11 @@ def extract_startnet(wim_path):
)
startnet_path = os.path.join(tmpdir, "startnet.cmd")
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 None
except Exception:
@@ -38,6 +68,10 @@ def update_startnet(wim_path, content):
"""
tmpdir = tempfile.mkdtemp()
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")
with open(startnet_path, "w", encoding="utf-8", newline="\r\n") as fh:
fh.write(content)
@@ -68,3 +102,626 @@ def list_files(wim_path, path="/"):
return []
except Exception:
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 %}
<style>
.cmd-editor {
font-family: 'Consolas', 'Courier New', monospace;
font-size: 0.9rem;
min-height: 600px;
height: 70vh;
resize: vertical;
width: 100%;
display: block;
background-color: #1e1e1e;
color: #d4d4d4;
/* ---- shared editor typography (keep pre + textarea metrics identical) ---- */
.stn-mono {
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
font-size: 13px;
line-height: 21px;
letter-spacing: normal;
tab-size: 4;
-moz-tab-size: 4;
}
/* ---- Raw tab: gutter + highlight overlay + textarea ---- */
.raw-editor-wrap {
display: flex;
border: 1px solid var(--border);
border-radius: 0.25rem;
padding: 1rem;
tab-size: 4;
background: #1e1e1e;
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;
}
.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;
}
.cmd-editor:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 0.15rem rgba(65, 129, 255, 0.25);
.diff-add { color: #6a9955; }
.diff-del { color: #f14c4c; }
.diff-hunk { color: #569cd6; }
.diff-meta { color: #808080; }
.diff-identical {
padding: 2rem;
text-align: center;
color: var(--success);
}
/* ---- misc ---- */
.wim-info dl { margin: 0; }
.wim-info dt {
font-weight: 600;
color: var(--text-light);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.wim-info dd {
margin: 0 0 0.65rem 0;
color: var(--text);
font-weight: 600; color: var(--text-light);
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
}
.wim-info dd { margin: 0 0 0.65rem 0; color: var(--text); }
.cmd-ref-item { margin-bottom: 0.85rem; }
.cmd-ref-item code {
display: block;
margin-bottom: 0.2rem;
word-break: break-all;
}
.cmd-ref-item code { display: block; margin-bottom: 0.2rem; word-break: break-all; }
.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>
{% endblock %}
@@ -52,9 +145,7 @@
<h1>startnet.cmd Editor</h1>
{% if wim_exists %}
<div class="header-actions">
<button type="submit" form="startnetForm" class="pxe-btn pxe-btn-primary">
<i class="bi bi-save"></i> Save to boot.wim
</button>
<span class="badge badge-secondary"><i class="bi bi-hdd"></i> boot.wim</span>
</div>
{% endif %}
</div>
@@ -64,88 +155,594 @@
<strong>boot.wim not found</strong> at <code>{{ wim_path }}</code>.
Run the PXE server setup playbook and import WinPE boot files first.
</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 %}
<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>
<span class="badge badge-secondary">boot.wim</span>
</div>
<form action="{{ url_for('startnet_save') }}" method="post" id="startnetForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<textarea name="content" class="cmd-editor" id="cmdEditor"
spellcheck="false">{{ content }}</textarea>
</form>
<div class="d-flex justify-content-between align-items-center mt-3">
<small class="text-muted">
Editing the 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>
<ul class="nav nav-tabs editor-tabs" id="stnTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-settings-btn" data-bs-toggle="tab" data-bs-target="#tab-settings" type="button" role="tab">
<i class="bi bi-sliders"></i> Settings
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-menu-btn" data-bs-toggle="tab" data-bs-target="#tab-menu" type="button" role="tab">
<i class="bi bi-list-ol"></i> Boot Menu
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-raw-btn" data-bs-toggle="tab" data-bs-target="#tab-raw" type="button" role="tab">
<i class="bi bi-code-slash"></i> Raw
{% if lint.errors %}<span class="badge badge-danger ms-1">{{ lint.errors|length }}</span>
{% elif lint.warnings %}<span class="badge badge-warning ms-1">{{ lint.warnings|length }}</span>{% endif %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-history-btn" data-bs-toggle="tab" data-bs-target="#tab-history" type="button" role="tab">
<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="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 class="tab-content tab-content-pad">
<!-- =============================== SETTINGS =============================== -->
<div class="tab-pane fade show active" id="tab-settings" role="tabpanel">
<div class="row">
<div class="col-lg-7">
<div class="pxe-card">
<h3 class="pxe-card-title">Boot Settings</h3>
<p class="text-light" style="font-size:13px;">
These edit only their targeted lines in startnet.cmd. Leave a field
<strong>blank to keep</strong> the current value. Everything round-trips
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 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 class="col-lg-5">
<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>
<div class="col-lg-3">
<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-muted mb-0">Could not read WIM info.</p>
{% endif %}
</dl>
<!-- =============================== BOOT MENU ============================== -->
<div class="tab-pane fade" id="tab-menu" role="tabpanel">
<div class="pxe-card">
<div class="d-flex justify-content-between align-items-center mb-2">
<h3 class="pxe-card-title mb-0">Boot Menu Entries</h3>
<button type="button" class="pxe-btn pxe-btn-secondary pxe-btn-sm" id="menuAddBtn">
<i class="bi bi-plus-lg"></i> Add Entry
</button>
</div>
<div class="alert alert-info" style="font-size:13px;">
<i class="bi bi-info-circle"></i>
<strong>Appending</strong> a new image at the end always works. Reordering or
removing an entry whose number the enrollment <code>%choice%</code> router
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>
{% endif %}
{% endblock %}
{% block extra_scripts %}
{% if wim_exists %}
<script>
// Tab key inserts a tab in the editor instead of moving focus
document.getElementById('cmdEditor')?.addEventListener('keydown', function(e) {
if (e.key === 'Tab') {
e.preventDefault();
var start = this.selectionStart;
var end = this.selectionEnd;
this.value = this.value.substring(0, start) + '\t' + this.value.substring(end);
this.selectionStart = this.selectionEnd = start + 1;
(function () {
"use strict";
var CSRF = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
var DIFF_URL = "{{ url_for('startnet_diff') }}";
// ------------------------------------------------------------------ escape
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>
{% endif %}
{% endblock %}