This commit is contained in:
2026-08-09 17:33:11 -05:00
parent d27811254d
commit 582d0e635d
35 changed files with 2944 additions and 1203 deletions

View File

@@ -81,7 +81,7 @@ client/src/
The backend already follows route/controller/model/form/service separation. The frontend now keeps top-level pages in `client/src/views` and uses reusable table, modal, dashboard-widget, and config-section components for repeated UI patterns. Future large screens should become view components first, with smaller domain-specific pieces under folders such as `components/scripts`, `components/hosts`, and `components/runplans`.
Tailwind CSS is installed through the Vite plugin and imported from `client/src/style.css`. Vuetify setup is centralized in `client/src/plugins/vuetify.js` so Material-style defaults, theme roles, density, icons, and component behavior are controlled in one place instead of scattered through views.
Tailwind CSS is installed through the Vite plugin and imported from `client/src/style.css`. Vuetify setup is centralized in `client/src/plugins/vuetify.js` so Material-style defaults, theme roles, density, icons, and component behavior are controlled in one place instead of scattered through views. The profile theme picker currently supports `dashtreme`, `terminal`, `aurora`, `greyscale`, and `macos`; row-level data-table actions use icon-only Material-style buttons with labels/tooltips, while page-level create/save commands keep text labels for clarity.
Dashboard widgets are intentionally split:
@@ -163,6 +163,14 @@ Important environment variables:
| `DEFAULT_ADMIN_NAME` | First-run admin display name. |
| `POWERSHELL_BIN` | Initial/default PowerShell executable. Defaults to `pwsh`; admins can override the live runtime value with Config -> Application Config -> Execution -> PowerShell Binary. |
| `ALLOW_SCRIPT_EXECUTION` | Set `false` to disable actual RunPlan execution. |
| `MYRTILLE_ENABLED` | Enables browser RDP launch through an external Myrtille gateway. Defaults to `false`. |
| `MYRTILLE_GATEWAY_URL` | Base URL to the Myrtille web app, for example `https://rdp-gateway.contoso.local/Myrtille/`. |
| `MYRTILLE_USE_PASSWORD_HASH` | When `true`, POSHManager asks Myrtille for a gateway-specific `passwordHash` before returning the launch URL. Defaults to `true`. |
| `MYRTILLE_ALLOW_PLAIN_PASSWORD` | Allows the legacy Myrtille `password` URL parameter when hash mode is disabled or fails. Defaults to `false`; use only for isolated HTTPS gateways. |
| `MYRTILLE_HASH_ENDPOINT` | Relative hash endpoint on the Myrtille gateway. Defaults to `GetHash.aspx`. |
| `MYRTILLE_DEFAULT_WIDTH` | Initial Myrtille desktop width. Defaults to `1280`. |
| `MYRTILLE_DEFAULT_HEIGHT` | Initial Myrtille desktop height. Defaults to `800`. |
| `MYRTILLE_REQUEST_TIMEOUT_MS` | Timeout for server-side Myrtille hash requests. Defaults to `15000`. |
| `VCENTER_ENABLED` | Enables VMware vCenter VM discovery and Host import workflows. |
| `VCENTER_BASE_URL` | vCenter REST API root, for example `https://vcenter.contoso.local`. |
| `VCENTER_USERNAME` | vCenter account used by the backend to create API sessions. |
@@ -274,9 +282,12 @@ Graph environments store Microsoft Entra app registration details used to call M
| `POST` | `/api/psadt/intune/deployments/:id/promote` | User | Advance a rollout ring's assignment intent (e.g. available → required). |
| `GET` | `/api/psadt/intune/builder/status` | User | Whether this host can build `.intunewin` (Windows + tool, or external packager). |
| `POST` | `/api/psadt/intune/deployments/:id/build` | User | Build a `.intunewin` from a source folder and ingest it into the Asset Library. |
| `GET` | `/api/psadt/intune/deployments/:id/datasheet` | User | Auto-generate a package datasheet (metadata, install/uninstall commands, detection, return codes, assignments, requirements, source/version). `?format=md\|html` (default `md`); `?download=1` to force a file download. |
| `GET` | `/api/packaging/installer-types` | User | List known installer technologies and their silent-switch catalog. |
| `POST` | `/api/packaging/analyze` | User | Detect installer technology from a file name and recommend install/uninstall commands. |
| `POST` | `/api/packaging/detection` | User | Generate an Intune detection rule (MSI product code, file+version, or registry). |
| `GET` | `/api/packaging/recipes` | User | Browse/search (`?q=`) the curated application recipe library. |
| `POST` | `/api/packaging/recipes/:id/apply` | User | Scaffold a draft package (PSADT profile + Intune deployment + tracked app) from a recipe. |
### Installer intelligence (packaging)
@@ -289,6 +300,21 @@ generates a detection rule in the deployment's `detectionType`/`detectionRule`
shape — an MSI product-code rule, or an Intune-style custom PowerShell script for
file-version or registry detection. The deployment modal's **Analyze installer**
control fills the install/uninstall commands directly.
### Application recipe library
The **Recipe Library** panel in the PSADT workbench is a curated catalog of ~30
common apps (browsers, Office/Microsoft 365 Apps, comms, developer tools, remote
access/virtualization, and security/enterprise agents such as CrowdStrike Falcon,
Splunk Universal Forwarder, SAP GUI, and Cisco Umbrella). Each recipe carries the
known-good silent switches, a detection footprint, and (where one exists) a
winget id. "Create package" (`POST /api/packaging/recipes/:id/apply`) scaffolds a
draft PSADT profile — with the raw silent install as an install task — plus an
Intune deployment (PSADT v4 wrapper and a generated detection rule), and, for
recipes with a winget id, a catalog application wired to the version watcher so
the app auto-tracks updates. Vendor-managed agents that need a CID, licence key,
or config file carry `<placeholder>` arguments as explicit refine-me prompts; the
result is always a draft to review before publishing.
| `GET` | `/api/psadt/intune/deployments/:id/graph/audit` | User | Return the audit trail of tenant-changing Graph actions for this deployment. |
| `GET` | `/api/intune/applications` | User | List catalog applications with computed update state. |
| `POST` | `/api/intune/applications` | User | Create a catalog application (groups versioned deployments). |
@@ -447,6 +473,10 @@ Theme payload:
{ "theme": "dashtreme" }
```
Supported theme IDs are `dashtreme`, `terminal`, `aurora`, `greyscale`, and
`macos`. The macOS theme is a desktop-style graphite/frosted mode with
toolbar-style chrome; greyscale is a neutral high-contrast enterprise mode.
### Users And Groups
Admin-only routes create users and groups. Groups are used for group-visible scripts, credentials, and RunPlans.
@@ -512,7 +542,7 @@ Payload:
| --- | --- | --- | --- |
| `GET` | `/api/hosts` | User | List host library. |
| `POST` | `/api/hosts` | User | Create host. |
| `POST` | `/api/hosts/:id/rdp-session` | User | Create a short-lived browser RDP launch session for a visible Windows host with an assigned visible username/password credential. Returns a same-origin `/rdp/:token` URL. |
| `POST` | `/api/hosts/:id/rdp-session` | User | Create a Myrtille browser RDP launch URL for a visible Windows host with an assigned visible username/password credential. |
| `GET` | `/api/hosts/import/vcenter/status` | User | Return legacy effective vCenter status plus visible saved VMware connection records. |
| `GET` | `/api/hosts/import/vcenter/connections` | User | List visible saved VMware connections. |
| `POST` | `/api/hosts/import/vcenter/connections` | User | Create a VMware connection record using a Credential Vault `credentialId`. Use `targetType: "vcenter"` for vCenter inventory or `targetType: "host"` for standalone ESXi Web Services inventory. |
@@ -565,12 +595,16 @@ guest identity when available; unknown guests are imported as `other`.
Windows hosts with an assigned visible username/password Credential Vault entry
show an RDP icon in the Hosts table. Clicking it calls
`POST /api/hosts/:id/rdp-session`, opens a same-origin `/rdp/:token`
tab, and renders a browser RDP canvas through `mstsc.js`. The browser receives
only a short-lived opaque launch token; POSHManager resolves the host address
and decrypts the assigned credential server-side. The RDP gateway uses the
default RDP port `3389`; PowerShell remoting `port` metadata remains separate
from RDP launch behavior.
`POST /api/hosts/:id/rdp-session`, which validates host/credential visibility,
decrypts the assigned credential server-side, and returns a Myrtille launch URL
for the configured gateway. POSHManager does not run the RDP protocol stack in
Node; Myrtille owns the browser session. By default POSHManager requests a
gateway-specific `passwordHash` from Myrtille and places that hash in the launch
URL instead of returning the plaintext vault password to the browser. The
plaintext Myrtille `password` parameter is available only when
`myrtille_allow_plain_password` / `MYRTILLE_ALLOW_PLAIN_PASSWORD` is explicitly
enabled. The RDP gateway uses the default RDP port `3389`; PowerShell remoting
`port` metadata remains separate from RDP launch behavior.
Host Groups let operators aggregate manual, imported, and VMware-sourced hosts
without duplicating RunPlans. Manual groups store an explicit list of visible
@@ -957,6 +991,23 @@ The PSADT Workbench models the PSAppDeployToolkit deployment lifecycle as first-
| `PUT` | `/api/psadt/profiles/:id` | User | Update a visible PSADT profile. |
| `DELETE` | `/api/psadt/profiles/:id` | User | Delete a visible PSADT profile. |
| `GET` | `/api/psadt/profiles/:id/render` | User | Render a profile into script scaffold and command-line metadata. |
| `POST` | `/api/psadt/profiles/:id/package` | User | Assemble a portable PSADT v4 package (rendered `Invoke-AppDeployToolkit.ps1` + `Files/`, `SupportFiles/`, `Assets/` from linked assets), zip it, and ingest the zip into the Asset Library. Pass `build: true` to also produce a `.intunewin` from the assembled tree. |
The package builder lays out a PSADT v4 tree from a profile and its linked Asset
Library items: the rendered entry script at the root, and each linked asset placed
at its link `packagePath` or, when none is set, under the folder implied by its
link role (`installer`/`package-file``Files/`, `support-file`/`detection-script`/`reference`
`SupportFiles/`, `icon``Assets/`). The result is zipped (no archiver
dependency; a built-in deflate ZIP writer) and stored as an archive asset you can
download via `GET /api/assets/:id/download`. With `build: true` and an installer
in the package, the assembled folder is also handed to the `.intunewin` builder
(`IntuneWinAppUtil.exe` on Windows, or `INTUNEWIN_BUILD_COMMAND`); the response
returns both assets plus a manifest and any warnings.
```jsonc
// POST /api/psadt/profiles/:id/package
{ "build": true, "setupFile": "Files/setup.exe" }
```
Profile payload:
@@ -1357,7 +1408,10 @@ Settings update:
"server_fqdn": "https://poshmanager.example.com",
"trusted_origins": "https://poshmanager.example.com",
"allow_script_execution": "true",
"entra_enabled": "false"
"entra_enabled": "false",
"myrtille_enabled": "true",
"myrtille_gateway_url": "https://rdp-gateway.contoso.local/Myrtille/",
"myrtille_use_password_hash": "true"
}
```
@@ -1389,7 +1443,7 @@ on the target platform before broad execution.
- SQLite access uses prepared statements through `node:sqlite`.
- Credential secrets are encrypted with AES-256-GCM and are not returned by API reads.
- Browser RDP sessions use `mstsc.js` with short-lived POSHManager launch tokens; host passwords stay in the API process and are never embedded in the Vue app or launch URL. The upstream `mstsc.js` dependency is legacy and currently brings transitive npm audit findings, so expose `/rdp` only through authenticated POSHManager/reverse-proxy paths and restrict network access to trusted operators.
- Browser RDP sessions are brokered to an external Myrtille gateway. POSHManager validates the host and Credential Vault entry, decrypts the password server-side, and by default requests a Myrtille `passwordHash` so the browser receives a hash-based launch URL rather than the plaintext vault secret. Keep `MYRTILLE_ALLOW_PLAIN_PASSWORD=false` unless the gateway is isolated, HTTPS-only, and explicitly approved for plaintext Myrtille URL launches.
- Request logs redact authorization and cookie headers.
- Protected routes use JWT auth.
- Admin-only APIs are guarded by `requireAdmin`.

View File

@@ -256,6 +256,7 @@
:installer-analysis="installerAnalysis"
:assets="assets"
:applications="catalogApplications"
:recipes="packagingRecipes"
:change-requests="changeRequests"
:reporting-overview="reportingOverview"
:validation-result="psadtValidationResult"
@@ -266,6 +267,7 @@
@delete-profile="deletePsadtProfile"
@render-profile="previewPsadtProfile"
@create-script="createScriptFromPsadtProfile"
@package-profile="packagePsadtProfile"
@insert-snippet="insertPsadtSnippet"
@validate-psadt="validatePsadtTarget"
@plan-migration="planPsadtMigration"
@@ -286,6 +288,7 @@
@check-drift="checkDrift"
@reconcile-graph-app="reconcileGraphApp"
@new-version="createDeploymentVersion"
@generate-datasheet="generateDeploymentDatasheet"
@build-intunewin="buildIntunewin"
@save-application="saveApplication"
@delete-application="deleteApplication"
@@ -295,6 +298,7 @@
@approve-change-request="approveChangeRequest"
@reject-change-request="rejectChangeRequest"
@analyze-installer="analyzeInstaller"
@apply-recipe="applyRecipe"
/>
<section v-if="view === 'hosts'" class="resource-page">
@@ -406,7 +410,13 @@
<template #cell-username="{ row }"><code>{{ row.username || '-' }}</code></template>
<template #cell-visibility="{ row }"><span :class="['status-pill', row.visibility]">{{ row.visibility }}</span></template>
<template #cell-status><span class="vault-state"><KeyRound :size="14" />Encrypted</span></template>
<template #cell-actions="{ row }"><button class="ghost-button compact" type="button" @click="openCredentialModal(row)">Edit</button></template>
<template #cell-actions="{ row }">
<div class="table-actions">
<button class="ghost-button compact icon-button" type="button" aria-label="Edit credential" title="Edit credential" @click="openCredentialModal(row)">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i>
</button>
</div>
</template>
</ResourceTable>
</section>
@@ -446,8 +456,12 @@
<template #cell-mode="{ row }">{{ row.parallel ? 'Parallel' : 'Serial' }}</template>
<template #cell-actions="{ row }">
<div class="table-actions">
<button class="ghost-button compact" type="button" @click="openRunPlanModal(row)">Edit</button>
<button class="primary-action compact" type="button" @click="executeRunPlan(row.id)"><Play :size="14" />Run</button>
<button class="ghost-button compact icon-button" type="button" aria-label="Edit RunPlan" title="Edit RunPlan" @click="openRunPlanModal(row)">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i>
</button>
<button class="primary-action compact icon-button" type="button" aria-label="Run RunPlan" title="Run RunPlan" @click="executeRunPlan(row.id)">
<i class="mdi mdi-play-circle-outline" aria-hidden="true"></i>
</button>
</div>
</template>
</ResourceTable>
@@ -1209,6 +1223,51 @@ const settingMetadata = {
description: 'Master safety switch that controls whether RunPlans can execute scripts on target hosts.',
section: 'Execution'
},
myrtille_enabled: {
label: 'Myrtille RDP Gateway',
description: 'Enable browser RDP launch through an external Myrtille gateway for Windows hosts.',
section: 'Integrations'
},
myrtille_gateway_url: {
label: 'Myrtille Gateway URL',
description: 'Base URL to the Myrtille web app. POSHManager opens generated launch URLs here.',
placeholder: 'https://rdp-gateway.contoso.local/Myrtille/',
section: 'Integrations'
},
myrtille_use_password_hash: {
label: 'Use Myrtille Password Hash',
description: 'Ask Myrtille to generate a gateway-specific passwordHash so plaintext passwords are not returned to the browser.',
section: 'Integrations'
},
myrtille_allow_plain_password: {
label: 'Allow Plaintext RDP URL Fallback',
description: 'Permit the legacy Myrtille password URL parameter if passwordHash generation is disabled or fails. Keep this off unless the gateway is isolated and HTTPS-only.',
section: 'Integrations'
},
myrtille_hash_endpoint: {
label: 'Myrtille Hash Endpoint',
description: 'Relative Myrtille endpoint used by the API to create passwordHash values.',
placeholder: 'GetHash.aspx',
section: 'Integrations'
},
myrtille_default_width: {
label: 'Myrtille Default Width',
description: 'Initial remote desktop width passed to Myrtille launch URLs.',
placeholder: '1280',
section: 'Integrations'
},
myrtille_default_height: {
label: 'Myrtille Default Height',
description: 'Initial remote desktop height passed to Myrtille launch URLs.',
placeholder: '800',
section: 'Integrations'
},
myrtille_request_timeout_ms: {
label: 'Myrtille Request Timeout',
description: 'Maximum time the API waits for Myrtille passwordHash generation before failing or falling back.',
placeholder: '15000',
section: 'Integrations'
},
host_group_sync_interval_minutes: {
label: 'Host Group Sync Interval',
description: 'Minutes between background sync runs for dynamic host groups. Set 0 to disable the scheduler.',
@@ -1286,6 +1345,7 @@ const psadtCatalog = ref({ module: {}, sources: [], supportMatrix: [], structure
const psadtProfiles = ref([]);
const psadtIntuneDeployments = ref([]);
const catalogApplications = ref([]);
const packagingRecipes = ref([]);
const changeRequests = ref([]);
const reportingOverview = ref(null);
const graphConnections = ref([]);
@@ -1437,6 +1497,18 @@ const availableThemes = [
name: 'Aurora Glass',
description: 'Cool blue and violet gradients with quiet contrast.',
colors: ['#5de7ff', '#7c5cff', '#f5f7ff']
},
{
id: 'greyscale',
name: 'Greyscale Pro',
description: 'Neutral graphite surfaces with high-contrast enterprise controls.',
colors: ['#f4f4f5', '#9ca3af', '#18181b']
},
{
id: 'macos',
name: 'macOS Desktop',
description: 'Classic desktop chrome, frosted panels, and calmer toolbar controls.',
colors: ['#f5f5f7', '#d1d5db', '#007aff']
}
];
@@ -2238,18 +2310,20 @@ async function deleteCustomVariable(id) {
}
async function refreshPsadt() {
const [catalogRows, profileRows, intuneRows, graphRows, applicationRows] = await Promise.all([
const [catalogRows, profileRows, intuneRows, graphRows, applicationRows, recipeRows] = await Promise.all([
api.get('/api/psadt/catalog'),
api.get('/api/psadt/profiles'),
api.get('/api/psadt/intune/deployments'),
api.get('/api/graph/connections'),
api.get('/api/intune/applications')
api.get('/api/intune/applications'),
api.get('/api/packaging/recipes').catch(() => [])
]);
psadtCatalog.value = catalogRows;
psadtProfiles.value = profileRows;
psadtIntuneDeployments.value = intuneRows;
graphConnections.value = graphRows;
catalogApplications.value = applicationRows;
packagingRecipes.value = recipeRows;
await refreshGovernance(true);
builderStatus.value = await api.get('/api/psadt/intune/builder/status').catch(() => null);
notify('PSADT Workbench refreshed');
@@ -2264,6 +2338,23 @@ async function analyzeInstaller(payload) {
}
}
async function applyRecipe(payload) {
try {
const result = await api.post(`/api/packaging/recipes/${payload.id}/apply`, {});
const [profileRows, intuneRows, applicationRows] = await Promise.all([
api.get('/api/psadt/profiles'),
api.get('/api/psadt/intune/deployments'),
api.get('/api/intune/applications')
]);
psadtProfiles.value = profileRows;
psadtIntuneDeployments.value = intuneRows;
catalogApplications.value = applicationRows;
notify(`Created draft package for ${result.profile?.name || 'recipe'}`);
} catch (err) {
notify(err.message, 'error');
}
}
async function buildIntunewin(payload) {
if (!payload.setupFile) return notify('Enter the setup file to build', 'error');
try {
@@ -2374,6 +2465,41 @@ async function previewPsadtProfile(id) {
notify('PSADT profile rendered into the editor');
}
async function packagePsadtProfile(id) {
try {
notify('Assembling PSADT package...');
const result = await api.post(`/api/psadt/profiles/${id}/package`, {});
const blob = await fetchAuthedBlob(`/api/assets/${result.asset.id}/download`);
const href = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = href;
link.download = result.asset.originalName;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(href);
await refreshAssets?.();
const fileCount = result.manifest?.fileCount ?? 0;
notify(`Packaged ${result.asset.originalName} (${fileCount} linked file${fileCount === 1 ? '' : 's'})`);
for (const warning of result.warnings || []) notify(warning, 'error');
} catch (err) {
notify(err.message, 'error');
}
}
async function generateDeploymentDatasheet(id) {
try {
notify('Generating package datasheet...');
const blob = await fetchAuthedBlob(`/api/psadt/intune/deployments/${id}/datasheet?format=html`);
const href = URL.createObjectURL(blob);
window.open(href, '_blank', 'noopener');
setTimeout(() => URL.revokeObjectURL(href), 60000);
notify('Package datasheet opened in a new tab');
} catch (err) {
notify(err.message, 'error');
}
}
async function createScriptFromPsadtProfile(id) {
const rendered = await api.get(`/api/psadt/profiles/${id}/render`);
const saved = await api.post('/api/scripts', {
@@ -2638,7 +2764,7 @@ function writeRdpLaunchPopup(popup, hostName, state = 'loading', message = '') {
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>${escapePreviewHtml(isError ? 'RDP launch failed' : `Opening RDP - ${hostName}`)}</title>
<title>${escapePreviewHtml(isError ? 'RDP launch failed' : `Opening Myrtille RDP - ${hostName}`)}</title>
<style>
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
* { box-sizing: border-box; }
@@ -2671,10 +2797,10 @@ function writeRdpLaunchPopup(popup, hostName, state = 'loading', message = '') {
</head>
<body>
<section>
<span class="eyebrow">${isError ? 'POSHManager RDP error' : 'POSHManager RDP'}</span>
<span class="eyebrow">${isError ? 'POSHManager RDP error' : 'Myrtille RDP'}</span>
<h1>${escapePreviewHtml(hostName)}</h1>
<p>${escapePreviewHtml(message || (isError ? 'The RDP launch failed.' : 'Preparing the browser RDP session...'))}</p>
<div class="status"><span class="pulse"></span><span>${isError ? 'Launch failed' : 'Creating secure launch token...'}</span></div>
<p>${escapePreviewHtml(message || (isError ? 'The RDP launch failed.' : 'Preparing the Myrtille browser session...'))}</p>
<div class="status"><span class="pulse"></span><span>${isError ? 'Launch failed' : 'Creating Myrtille launch URL...'}</span></div>
</section>
</body>
</html>`);
@@ -2684,6 +2810,9 @@ function writeRdpLaunchPopup(popup, hostName, state = 'loading', message = '') {
async function launchRdpHost(host) {
if (!canLaunchRdp(host)) return;
const popup = window.open('about:blank', '_blank');
if (popup) {
try { popup.opener = null; } catch (err) { /* noop */ }
}
writeRdpLaunchPopup(popup, host.name, 'loading');
try {
const result = await api.post(`/api/hosts/${host.id}/rdp-session`, {});

View File

@@ -28,9 +28,15 @@
<b>{{ connection.name }}</b>
<small>{{ connection.cloud }} - {{ connection.tenantId }} - {{ connection.lastTestStatus || 'untested' }}</small>
<span>
<button class="ghost-button compact" type="button" @click="openModal(connection)">Edit</button>
<button class="ghost-button compact" type="button" @click="$emit('test', connection.id)">Test</button>
<button class="ghost-button compact danger-text" type="button" @click="$emit('delete', connection.id)">Delete</button>
<button class="ghost-button compact icon-button" type="button" aria-label="Edit Graph environment" title="Edit Graph environment" @click="openModal(connection)">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i>
</button>
<button class="ghost-button compact icon-button" type="button" aria-label="Test Graph environment" title="Test Graph environment" @click="$emit('test', connection.id)">
<i class="mdi mdi-lan-connect" aria-hidden="true"></i>
</button>
<button class="ghost-button compact icon-button danger-text" type="button" aria-label="Delete Graph environment" title="Delete Graph environment" @click="$emit('delete', connection.id)">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
</button>
</span>
</div>
<p v-if="!graphConnections.length" class="widget-empty">No Microsoft Graph Intune environments configured.</p>

View File

@@ -142,6 +142,69 @@ button {
radial-gradient(circle at 76% 28%, rgba(124, 92, 255, .18), transparent 30%);
}
.shell.theme-greyscale {
--bg: #09090b;
--panel: rgba(24, 24, 27, .82);
--panel-strong: rgba(24, 24, 27, .96);
--border: rgba(228, 228, 231, .14);
--border-strong: rgba(244, 244, 245, .3);
--text: #f4f4f5;
--muted: #c5c5cc;
--subtle: #8e8e96;
--cyan: #f4f4f5;
--cyan-soft: rgba(244, 244, 245, .1);
--violet: #a1a1aa;
--mint: #d4d4d8;
--amber: #e4e4e7;
--red: #f87171;
--action-gradient: linear-gradient(135deg, #ffffff 0%, #d4d4d8 52%, #71717a 100%);
--surface-gradient: linear-gradient(145deg, rgba(39, 39, 42, .9), rgba(12, 12, 14, .84));
--shadow: 0 20px 70px rgba(0, 0, 0, .34);
--shell-atmosphere: radial-gradient(circle at 22% 14%, rgba(255, 255, 255, .12), transparent 30%),
radial-gradient(circle at 76% 28%, rgba(161, 161, 170, .13), transparent 34%);
--shell-atmosphere-opacity: .88;
}
.shell.theme-macos {
--bg: #101114;
--panel: rgba(38, 39, 43, .76);
--panel-strong: rgba(28, 29, 33, .96);
--border: rgba(255, 255, 255, .16);
--border-strong: rgba(10, 132, 255, .42);
--text: #f5f5f7;
--muted: #c7c7cc;
--subtle: #8e8e93;
--cyan: #0a84ff;
--cyan-soft: rgba(10, 132, 255, .13);
--violet: #5e5ce6;
--mint: #30d158;
--amber: #ffd60a;
--red: #ff453a;
--action-gradient: linear-gradient(180deg, #0a84ff 0%, #006edb 100%);
--surface-gradient: linear-gradient(180deg, rgba(64, 65, 72, .78), rgba(25, 26, 31, .76));
--radius: 13px;
--display-font: -apple-system, BlinkMacSystemFont, "SF Pro Display", Inter, "Segoe UI", system-ui, sans-serif;
--shadow: 0 22px 80px rgba(0, 0, 0, .28);
--shell-atmosphere: radial-gradient(circle at 24% 12%, rgba(10, 132, 255, .18), transparent 32%),
radial-gradient(circle at 78% 26%, rgba(94, 92, 230, .16), transparent 34%),
linear-gradient(145deg, rgba(255, 255, 255, .08), transparent 38%);
--shell-atmosphere-opacity: .74;
}
body:has(.shell.theme-greyscale) {
background:
radial-gradient(circle at 20% 16%, rgba(255, 255, 255, .1), transparent 30%),
radial-gradient(circle at 78% 24%, rgba(113, 113, 122, .16), transparent 34%),
linear-gradient(135deg, #030305 0%, #18181b 42%, #09090b 100%);
}
body:has(.shell.theme-macos) {
background:
radial-gradient(circle at 20% 8%, rgba(10, 132, 255, .22), transparent 34%),
radial-gradient(circle at 82% 20%, rgba(94, 92, 230, .18), transparent 32%),
linear-gradient(150deg, #34363c 0%, #1d1f25 48%, #0e1015 100%);
}
.particle-field {
position: fixed;
inset: 0;
@@ -6463,6 +6526,54 @@ body {
}
.installer-candidates .status-pill:hover { background: rgba(255, 255, 255, 0.12); }
/* Recipe Library (Phase 2) */
.recipe-panel .recipe-search {
min-width: 200px;
padding: 0.45rem 0.7rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.05);
color: inherit;
}
.recipe-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.75rem;
}
.recipe-card {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.8rem;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
}
.recipe-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.recipe-vendor { opacity: 0.7; }
.recipe-args {
font-size: 0.72rem;
padding: 0.3rem 0.45rem;
border-radius: 8px;
background: rgba(0, 0, 0, 0.22);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recipe-card-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-top: auto;
}
.recipe-link { font-size: 0.78rem; opacity: 0.85; }
/* Material + Tailwind polish layer */
html {
scroll-behavior: smooth;
@@ -8281,3 +8392,308 @@ input:read-only {
margin: 0 !important;
padding: 0 !important;
}
/* Theme refinement layer: keeps repeated operational surfaces consistent across modes. */
.shell.theme-greyscale .particle-field {
opacity: .38;
filter: grayscale(1);
}
.shell.theme-greyscale {
filter: grayscale(.95) saturate(.24);
}
.shell.theme-greyscale::before {
background:
radial-gradient(circle at 18% 16%, rgba(255, 255, 255, .1), transparent 32%),
radial-gradient(circle at 78% 24%, rgba(113, 113, 122, .14), transparent 36%),
linear-gradient(135deg, #050506 0%, #18181b 42%, #09090b 100%);
}
.shell.theme-greyscale .particle-field span {
background: rgba(244, 244, 245, .72);
box-shadow: 0 0 18px rgba(244, 244, 245, .32);
}
.shell.theme-greyscale .glass-panel,
.shell.theme-greyscale .sidebar,
.shell.theme-greyscale .topbar,
.shell.theme-greyscale .resource-table-panel,
.shell.theme-greyscale .form-modal {
background:
linear-gradient(180deg, rgba(255, 255, 255, .07), rgba(255, 255, 255, .026)),
rgba(18, 18, 20, .82);
border-color: rgba(244, 244, 245, .15);
box-shadow:
0 22px 80px rgba(0, 0, 0, .36),
inset 0 1px 0 rgba(255, 255, 255, .07);
}
.shell.theme-greyscale .resource-toolbar,
.shell.theme-greyscale .search-control,
.shell.theme-greyscale .table-density-select,
.shell.theme-greyscale .data-table-wrap,
.shell.theme-greyscale .resource-empty-shell {
background:
linear-gradient(180deg, rgba(255, 255, 255, .055), rgba(255, 255, 255, .018)),
rgba(10, 10, 12, .72);
border-color: rgba(244, 244, 245, .14);
}
.shell.theme-greyscale .metismenu button:hover,
.shell.theme-greyscale .metismenu button.active,
.shell.theme-greyscale .nav-group.active > .nav-group-toggle {
background: linear-gradient(90deg, rgba(244, 244, 245, .12), rgba(161, 161, 170, .08));
border-color: rgba(244, 244, 245, .16);
}
.shell.theme-greyscale .metismenu button.active .parent-icon,
.shell.theme-greyscale .nav-group.active > .nav-group-toggle .parent-icon {
background: linear-gradient(135deg, rgba(244, 244, 245, .34), rgba(113, 113, 122, .2));
}
.shell.theme-macos {
letter-spacing: 0;
}
.shell.theme-macos::before {
background:
radial-gradient(circle at 24% 12%, rgba(10, 132, 255, .22), transparent 32%),
radial-gradient(circle at 78% 26%, rgba(94, 92, 230, .18), transparent 34%),
linear-gradient(150deg, #34363c 0%, #1d1f25 48%, #0e1015 100%);
}
.shell.theme-macos .particle-field {
opacity: .28;
filter: saturate(.82) blur(.1px);
}
.shell.theme-macos .particle-field span {
width: 3px;
height: 3px;
background: rgba(255, 255, 255, .62);
box-shadow: 0 0 18px rgba(10, 132, 255, .34);
}
.shell.theme-macos .sidebar,
.shell.theme-macos .topbar,
.shell.theme-macos .glass-panel,
.shell.theme-macos .resource-table-panel,
.shell.theme-macos .form-modal,
.shell.theme-macos .profile-menu-card {
border-color: rgba(255, 255, 255, .18);
background:
linear-gradient(180deg, rgba(255, 255, 255, .09), rgba(255, 255, 255, .035)),
rgba(30, 31, 36, .72);
box-shadow:
0 24px 86px rgba(0, 0, 0, .3),
inset 0 1px 0 rgba(255, 255, 255, .16);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
}
.shell.theme-macos .topbar {
padding-left: 86px;
border-radius: 16px;
}
.shell.theme-macos .topbar::before {
content: "";
position: absolute;
left: 22px;
top: 50%;
width: 12px;
height: 12px;
border-radius: 50%;
translate: 0 -50%;
background: #ff5f57;
box-shadow:
20px 0 0 #ffbd2e,
40px 0 0 #28c840,
inset 0 0 0 1px rgba(0, 0, 0, .18),
20px 0 0 0 #ffbd2e,
40px 0 0 0 #28c840;
}
.shell.theme-macos .sidebar {
border-radius: 16px;
}
.shell.theme-macos .sidebar-header,
.shell.theme-macos .sidebar-user,
.shell.theme-macos .topbar-search,
.shell.theme-macos .resource-toolbar,
.shell.theme-macos .search-control,
.shell.theme-macos .table-density-select {
background:
linear-gradient(180deg, rgba(255, 255, 255, .08), rgba(255, 255, 255, .03)),
rgba(18, 19, 23, .4);
}
.shell.theme-macos .primary-action {
color: #fff;
border-color: rgba(10, 132, 255, .58);
box-shadow:
0 12px 34px rgba(10, 132, 255, .2),
inset 0 1px 0 rgba(255, 255, 255, .24);
}
.shell.theme-macos .primary-action:hover {
box-shadow:
0 16px 42px rgba(10, 132, 255, .26),
inset 0 1px 0 rgba(255, 255, 255, .3);
}
.shell.theme-macos .metismenu button:hover,
.shell.theme-macos .metismenu button.active,
.shell.theme-macos .nav-group.active > .nav-group-toggle {
background: rgba(10, 132, 255, .14);
border-color: rgba(10, 132, 255, .24);
box-shadow: inset 3px 0 0 rgba(10, 132, 255, .84);
}
.shell.theme-macos .metismenu button.active .parent-icon,
.shell.theme-macos .nav-group.active > .nav-group-toggle .parent-icon {
background: linear-gradient(180deg, rgba(10, 132, 255, .86), rgba(0, 98, 200, .86));
}
.theme-card-greyscale {
background:
linear-gradient(145deg, rgba(255, 255, 255, .08), rgba(255, 255, 255, .025)),
rgba(18, 18, 20, .76);
}
.theme-card-macos {
background:
radial-gradient(circle at 18% 0, rgba(10, 132, 255, .18), transparent 42%),
linear-gradient(180deg, rgba(255, 255, 255, .1), rgba(255, 255, 255, .035)),
rgba(28, 29, 33, .74);
}
.table-actions,
.table-action-row,
.job-output-row-actions,
.publisher-actions,
.check-row > span {
align-items: center;
min-width: 0;
}
.sidebar,
.metismenu,
.topbar,
.resource-table-panel,
.resource-toolbar {
min-width: 0;
}
.sidebar {
overflow-x: hidden;
}
.resource-table-panel {
max-width: 100%;
}
.resource-toolbar {
overflow: hidden;
}
.table-actions-cell {
width: 1%;
min-width: 132px;
white-space: nowrap;
}
.table-actions .icon-button,
.table-action-row .icon-button,
.job-output-row-actions .icon-only,
.check-row > span .icon-button {
width: 36px;
min-width: 36px;
height: 36px;
min-height: 36px;
padding: 0;
border-radius: 12px;
font-size: 0;
}
.table-actions .icon-button svg,
.table-action-row .icon-button svg,
.table-actions .icon-button i,
.table-action-row .icon-button i,
.job-output-row-actions .icon-only i,
.check-row > span .icon-button i {
width: 17px;
height: 17px;
margin: 0;
font-size: 17px;
line-height: 1;
}
.table-actions .icon-button:not(.primary-action),
.table-action-row .icon-button:not(.primary-action),
.job-output-row-actions .icon-only {
color: rgba(230, 239, 250, .9);
border-color: rgba(156, 199, 232, .18);
background:
linear-gradient(180deg, rgba(255, 255, 255, .095), rgba(255, 255, 255, .035)),
rgba(8, 16, 28, .58);
}
.table-actions .icon-button:not(.primary-action):hover,
.table-action-row .icon-button:not(.primary-action):hover,
.job-output-row-actions .icon-only:hover {
color: var(--text);
border-color: var(--border-strong);
background:
radial-gradient(circle at 24% 0, var(--cyan-soft), transparent 46%),
linear-gradient(180deg, rgba(255, 255, 255, .13), rgba(255, 255, 255, .045)),
rgba(9, 18, 32, .74);
}
.table-actions .icon-button.danger-text,
.table-action-row .icon-button.danger-text {
color: #ff9ca8;
}
.data-table tbody tr {
background:
linear-gradient(90deg, rgba(255, 255, 255, .018), transparent 34%);
}
.data-table tbody tr:nth-child(even) {
background:
linear-gradient(90deg, rgba(255, 255, 255, .032), transparent 34%);
}
.shell.theme-greyscale .data-table th,
.shell.theme-greyscale .resource-table th button,
.shell.theme-greyscale .resource-filter-strip span {
color: rgba(244, 244, 245, .78);
}
.shell.theme-greyscale .data-table tbody tr:hover {
background: linear-gradient(90deg, rgba(244, 244, 245, .09), rgba(244, 244, 245, .025));
box-shadow: inset 3px 0 0 rgba(244, 244, 245, .48);
}
.shell.theme-macos .data-table th {
color: rgba(235, 235, 245, .72);
background: linear-gradient(180deg, rgba(58, 60, 68, .98), rgba(28, 30, 36, .98));
}
.shell.theme-macos .data-table tbody tr:hover {
background: linear-gradient(90deg, rgba(10, 132, 255, .11), rgba(10, 132, 255, .025));
box-shadow: inset 3px 0 0 rgba(10, 132, 255, .72);
}
@media (max-width: 760px) {
.shell.theme-macos .topbar {
padding-left: 24px;
}
.shell.theme-macos .topbar::before {
display: none;
}
}

View File

@@ -96,9 +96,9 @@
<template #cell-packagePath="{ row }"><code>{{ row.packagePath || row.originalName }}</code></template>
<template #cell-actions="{ row }">
<div class="table-action-row">
<button class="ghost-button compact" type="button" @click="selectAsset(row); viewAsset(row)"><Eye :size="14" />View</button>
<button class="ghost-button compact" type="button" @click="downloadAsset(row)"><Download :size="14" />Download</button>
<button class="ghost-button compact danger-text" type="button" @click="deleteAsset(row)"><Trash2 :size="14" /></button>
<button class="ghost-button compact icon-button" type="button" aria-label="View asset" title="View asset" @click="selectAsset(row); viewAsset(row)"><Eye :size="15" /></button>
<button class="ghost-button compact icon-button" type="button" aria-label="Download asset" title="Download asset" @click="downloadAsset(row)"><Download :size="15" /></button>
<button class="ghost-button compact icon-button danger-text" type="button" aria-label="Delete asset" title="Delete asset" @click="deleteAsset(row)"><Trash2 :size="15" /></button>
</div>
</template>
<template #empty>

View File

@@ -306,8 +306,12 @@
<td>{{ app.deploymentCount }}</td>
<td>
<div class="table-actions">
<button class="ghost-button compact" type="button" @click="openApplicationModal(app)">Edit</button>
<button class="ghost-button compact" type="button" @click="$emit('check-application-update', app.id)"><RefreshCw :size="13" />Check</button>
<button class="ghost-button compact icon-button" type="button" aria-label="Edit application" title="Edit application" @click="openApplicationModal(app)">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i>
</button>
<button class="ghost-button compact icon-button" type="button" aria-label="Check for application update" title="Check for application update" @click="$emit('check-application-update', app.id)">
<i class="mdi mdi-refresh" aria-hidden="true"></i>
</button>
</div>
</td>
</tr>
@@ -317,6 +321,33 @@
</div>
</article>
<article class="glass-panel psadt-panel recipe-panel">
<div class="section-title">
<div>
<h3>Recipe Library</h3>
<p>One-click drafts for common apps pre-filled silent switches, detection, and winget update tracking. Creates a PSADT profile + Intune deployment you can refine before publishing.</p>
</div>
<div class="table-actions">
<input v-model="recipeSearch" class="recipe-search" type="search" placeholder="Search apps…" aria-label="Search recipes" />
</div>
</div>
<div class="recipe-grid">
<div v-for="recipe in filteredRecipes" :key="recipe.id" class="recipe-card">
<div class="recipe-card-head">
<strong>{{ recipe.name }}</strong>
<span class="status-pill">{{ recipe.installerType }}</span>
</div>
<small class="recipe-vendor">{{ recipe.vendor }} · {{ recipe.category }}</small>
<code class="recipe-args" :title="recipe.installArgs">{{ recipe.installArgs || '—' }}</code>
<div class="recipe-card-foot">
<a v-if="recipe.homepage" :href="recipe.homepage" target="_blank" rel="noopener" class="recipe-link">Source</a>
<button class="primary-action compact" type="button" @click="$emit('apply-recipe', { id: recipe.id })"><PackagePlus :size="14" />Create package</button>
</div>
</div>
</div>
<div v-if="!filteredRecipes.length" class="widget-empty">No recipes match your search.</div>
</article>
<article class="glass-panel psadt-panel governance-panel">
<div class="section-title">
<div>
@@ -398,9 +429,18 @@
<template #cell-assignments="{ row }">{{ row.assignments?.map((item) => item.ring).join(', ') || '-' }}</template>
<template #cell-actions="{ row }">
<div class="table-actions">
<button class="ghost-button compact" type="button" @click="openIntuneDeploymentModal(row)">Edit</button>
<button class="ghost-button compact" type="button" @click="$emit('new-version', row.id)"><PackagePlus :size="13" />New version</button>
<button class="ghost-button compact danger-text" type="button" @click="$emit('delete-intune-deployment', row.id)">Delete</button>
<button class="ghost-button compact icon-button" type="button" aria-label="Edit Intune deployment plan" title="Edit Intune deployment plan" @click="openIntuneDeploymentModal(row)">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i>
</button>
<button class="ghost-button compact icon-button" type="button" aria-label="Create new deployment version" title="Create new deployment version" @click="$emit('new-version', row.id)">
<i class="mdi mdi-source-branch-plus" aria-hidden="true"></i>
</button>
<button class="ghost-button compact icon-button" type="button" aria-label="Generate package documentation" title="Generate package documentation" @click="$emit('generate-datasheet', row.id)">
<i class="mdi mdi-file-document-outline" aria-hidden="true"></i>
</button>
<button class="ghost-button compact icon-button danger-text" type="button" aria-label="Delete Intune deployment plan" title="Delete Intune deployment plan" @click="$emit('delete-intune-deployment', row.id)">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
</button>
</div>
</template>
</ResourceTable>
@@ -573,9 +613,18 @@
</template>
<template #cell-actions="{ row }">
<div class="table-actions">
<button class="ghost-button compact" type="button" @click="openProfileModal(row)">Edit</button>
<button class="ghost-button compact" type="button" @click="$emit('render-profile', row.id)">Preview</button>
<button class="primary-action compact" type="button" @click="$emit('create-script', row.id)"><FilePlus2 :size="14" />Create script</button>
<button class="ghost-button compact icon-button" type="button" aria-label="Edit PSADT profile" title="Edit PSADT profile" @click="openProfileModal(row)">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i>
</button>
<button class="ghost-button compact icon-button" type="button" aria-label="Preview rendered PSADT profile" title="Preview rendered PSADT profile" @click="$emit('render-profile', row.id)">
<i class="mdi mdi-eye-outline" aria-hidden="true"></i>
</button>
<button class="ghost-button compact icon-button" type="button" aria-label="Export PSADT package (.zip)" title="Export PSADT package (.zip)" @click="$emit('package-profile', row.id)">
<i class="mdi mdi-folder-zip-outline" aria-hidden="true"></i>
</button>
<button class="primary-action compact icon-button" type="button" aria-label="Create script from PSADT profile" title="Create script from PSADT profile" @click="$emit('create-script', row.id)">
<i class="mdi mdi-file-plus-outline" aria-hidden="true"></i>
</button>
</div>
</template>
</ResourceTable>
@@ -1005,6 +1054,7 @@ const props = defineProps({
builderStatus: { type: Object, default: null },
assets: { type: Array, default: () => [] },
applications: { type: Array, default: () => [] },
recipes: { type: Array, default: () => [] },
changeRequests: { type: Array, default: () => [] },
reportingOverview: { type: Object, default: null },
installerAnalysis: { type: Object, default: null },
@@ -1019,6 +1069,7 @@ const emit = defineEmits([
'delete-profile',
'render-profile',
'create-script',
'package-profile',
'insert-snippet',
'validate-psadt',
'plan-migration',
@@ -1039,6 +1090,7 @@ const emit = defineEmits([
'check-drift',
'reconcile-graph-app',
'new-version',
'generate-datasheet',
'build-intunewin',
'save-application',
'delete-application',
@@ -1047,9 +1099,19 @@ const emit = defineEmits([
'refresh-governance',
'approve-change-request',
'reject-change-request',
'analyze-installer'
'analyze-installer',
'apply-recipe'
]);
const recipeSearch = ref('');
const filteredRecipes = computed(() => {
const q = recipeSearch.value.trim().toLowerCase();
if (!q) return props.recipes;
return props.recipes.filter((r) =>
[r.vendor, r.name, r.category, r.wingetId].some((field) => String(field || '').toLowerCase().includes(q))
);
});
const profileModalOpen = ref(false);
const intuneModalOpen = ref(false);
const graphModalOpen = ref(false);

View File

@@ -135,9 +135,9 @@
<template #cell-nextRunAt="{ row }">{{ shortDate(row.nextRunAt) || 'Not scheduled' }}</template>
<template #cell-actions="{ row }">
<div class="table-actions">
<button class="ghost-button compact" type="button" @click="openModal(row)"><i class="mdi mdi-pencil-outline" />Edit</button>
<button class="ghost-button compact" type="button" @click="$emit('run-now', row.id)"><i class="mdi mdi-play-circle-outline" />Run now</button>
<button class="ghost-button compact danger-text" type="button" @click="confirmDelete(row)"><i class="mdi mdi-trash-can-outline" />Delete</button>
<button class="ghost-button compact icon-button" type="button" aria-label="Edit schedule" title="Edit schedule" @click="openModal(row)"><i class="mdi mdi-pencil-outline" aria-hidden="true" /></button>
<button class="ghost-button compact icon-button" type="button" aria-label="Run schedule now" title="Run schedule now" @click="$emit('run-now', row.id)"><i class="mdi mdi-play-circle-outline" aria-hidden="true" /></button>
<button class="ghost-button compact icon-button danger-text" type="button" aria-label="Delete schedule" title="Delete schedule" @click="confirmDelete(row)"><i class="mdi mdi-trash-can-outline" aria-hidden="true" /></button>
</div>
</template>
</ResourceTable>
@@ -170,8 +170,11 @@
<form class="schedule-modal-form" @submit.prevent="submitSchedule">
<section class="schedule-form-section">
<button class="deployment-section-toggle" type="button" @click="toggleSection('details')">
<span><i class="mdi mdi-form-textbox" />Details</span>
<span>RunPlan ownership and visibility</span>
<span class="schedule-section-icon" aria-hidden="true"><i class="mdi mdi-card-text-outline"></i></span>
<span class="schedule-section-copy">
<strong>Details</strong>
<small>RunPlan ownership and visibility</small>
</span>
<i :class="['mdi mdi-chevron-down', { open: !collapsed.details }]" />
</button>
<div v-if="!collapsed.details" class="form-grid two">
@@ -207,8 +210,11 @@
<section class="schedule-form-section">
<button class="deployment-section-toggle" type="button" @click="toggleSection('cadence')">
<span><i class="mdi mdi-calendar-sync-outline" />Cadence</span>
<span>{{ cadenceLabel(scheduleForm) }}</span>
<span class="schedule-section-icon" aria-hidden="true"><i class="mdi mdi-calendar-clock-outline"></i></span>
<span class="schedule-section-copy">
<strong>Cadence</strong>
<small>{{ cadenceLabel(scheduleForm) }}</small>
</span>
<i :class="['mdi mdi-chevron-down', { open: !collapsed.cadence }]" />
</button>
<div v-if="!collapsed.cadence" class="form-grid two">
@@ -271,8 +277,11 @@
<section class="schedule-form-section">
<button class="deployment-section-toggle" type="button" @click="toggleSection('preview')">
<span><i class="mdi mdi-clock-fast" />Preview</span>
<span>{{ previewOccurrences.length }} generated occurrence(s)</span>
<span class="schedule-section-icon" aria-hidden="true"><i class="mdi mdi-timeline-clock-outline"></i></span>
<span class="schedule-section-copy">
<strong>Preview</strong>
<small>{{ previewOccurrences.length }} generated occurrence(s)</small>
</span>
<i :class="['mdi mdi-chevron-down', { open: !collapsed.preview }]" />
</button>
<div v-if="!collapsed.preview" class="schedule-preview-grid">
@@ -957,29 +966,84 @@ function relativeDue(value) {
overflow-wrap: anywhere;
}
:global(.modal-backdrop:has(.schedule-modal-form)) {
padding: 14px;
overflow: hidden;
}
:global(html:has(.schedule-modal-form)),
:global(body:has(.schedule-modal-form)),
:global(body:has(.schedule-modal-form) #app) {
min-width: 0;
max-width: 100dvw;
overflow-x: hidden;
}
:global(.form-modal:has(.schedule-modal-form)) {
width: min(1180px, calc(100dvw - 28px));
max-width: calc(100dvw - 28px);
height: min(842px, calc(100dvh - 28px));
max-height: calc(100dvh - 28px);
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
}
:global(.form-modal:has(.schedule-modal-form) > header) {
flex: 0 0 auto;
min-width: 0;
margin: 0;
padding: 20px 24px 18px;
background:
radial-gradient(circle at 0 0, rgba(88, 221, 255, .14), transparent 38%),
linear-gradient(90deg, rgba(35, 49, 80, .78), rgba(20, 22, 48, .88));
}
:global(.form-modal:has(.schedule-modal-form) > header > div) {
min-width: 0;
}
:global(.form-modal:has(.schedule-modal-form) > header h3),
:global(.form-modal:has(.schedule-modal-form) > header p) {
max-width: 100%;
overflow-wrap: anywhere;
}
.schedule-modal-form {
display: grid;
gap: 12px;
max-height: min(78vh, 820px);
overflow: auto;
padding-right: 4px;
min-width: 0;
min-height: 0;
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 14px;
max-height: none;
overflow-y: auto;
overflow-x: hidden;
padding: 20px 24px 0;
scrollbar-gutter: stable;
}
.schedule-form-section {
flex: 0 0 auto;
min-width: 0;
border: 1px solid rgba(139, 224, 255, .12);
border-radius: 18px;
background: rgba(5, 9, 21, .38);
border-radius: 20px;
background:
radial-gradient(circle at 0 0, rgba(88, 221, 255, .07), transparent 32%),
linear-gradient(145deg, rgba(14, 22, 43, .82), rgba(6, 11, 26, .72));
overflow: hidden;
box-shadow: 0 16px 36px rgba(0, 0, 0, .18);
}
.schedule-form-section .deployment-section-toggle {
display: grid;
grid-template-columns: minmax(160px, auto) minmax(0, 1fr) auto;
grid-template-columns: 38px minmax(0, 1fr) 28px;
align-items: center;
gap: 14px;
gap: 12px;
width: 100%;
min-height: 64px;
padding: 14px 16px;
min-height: 70px;
padding: 14px 15px;
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, .05);
color: #f4f7ff;
@@ -987,42 +1051,73 @@ function relativeDue(value) {
text-align: left;
}
.schedule-form-section .deployment-section-toggle > span:first-child {
display: inline-flex;
align-items: center;
gap: 10px;
color: #8fe8ff;
font-size: 12px;
font-weight: 900;
letter-spacing: 0;
text-transform: none;
}
.schedule-form-section .deployment-section-toggle > span:first-child i {
display: inline-grid;
.schedule-section-icon {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border: 1px solid rgba(143, 232, 255, .2);
align-self: center;
flex: 0 0 38px;
width: 38px;
height: 38px;
min-width: 38px;
min-height: 38px;
aspect-ratio: 1 / 1;
border: 1px solid rgba(143, 232, 255, .24);
border-radius: 12px;
background: rgba(143, 232, 255, .08);
color: #8fe8ff;
line-height: 0;
background:
radial-gradient(circle at 30% 16%, rgba(143, 232, 255, .18), transparent 45%),
rgba(143, 232, 255, .08);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .08);
}
.schedule-form-section .deployment-section-toggle > span:nth-child(2) {
.schedule-section-icon i {
display: grid;
place-items: center;
width: 18px;
height: 18px;
font-size: 18px;
line-height: 1;
}
.schedule-section-icon i::before {
display: block;
width: 1em;
height: 1em;
line-height: 1;
}
.schedule-section-copy {
display: grid;
gap: 3px;
min-width: 0;
color: rgba(231, 239, 255, .82);
font-size: 13px;
font-weight: 700;
}
.schedule-section-copy strong {
color: #8fe8ff;
font: 900 14px/1.1 var(--display-font);
letter-spacing: 0;
text-transform: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.schedule-section-copy small {
color: rgba(205, 218, 238, .68);
font: 700 12px/1.35 var(--body-font);
letter-spacing: 0;
text-transform: none;
overflow-wrap: anywhere;
}
.schedule-form-section .deployment-section-toggle > i:last-child {
grid-column: 3;
justify-self: end;
display: grid;
place-items: center;
width: 28px;
height: 28px;
color: rgba(232, 240, 255, .72);
font-size: 18px;
line-height: 1;
transition: transform .18s ease;
}
@@ -1033,33 +1128,84 @@ function relativeDue(value) {
.schedule-form-section .form-grid {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
padding: 14px;
gap: 16px;
min-width: 0;
padding: 16px;
}
.schedule-form-section .form-grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr));
}
.schedule-form-section .form-grid .full {
grid-column: 1 / -1;
}
.schedule-form-section .modal-toggle {
min-height: 76px;
.schedule-modal-form .schedule-form-section .modal-toggle {
grid-column: auto;
width: min(100%, 360px);
min-height: 70px;
display: grid !important;
grid-template-columns: minmax(0, 1fr) 48px;
align-items: center;
justify-content: start;
gap: 14px;
padding: 12px 16px !important;
}
.schedule-form-section .modal-toggle input[type="checkbox"] {
width: 46px;
min-width: 46px;
height: 26px;
.schedule-modal-form .schedule-form-section .modal-toggle input[type="checkbox"] {
grid-column: 2;
grid-row: 1;
justify-self: end;
align-self: center;
flex-basis: 46px !important;
width: 46px !important;
min-width: 46px !important;
max-width: 46px !important;
height: 26px !important;
min-height: 26px !important;
}
.schedule-form-section .modal-toggle input[type="checkbox"]::after {
.schedule-modal-form .schedule-form-section .modal-toggle input[type="checkbox"]::after {
top: 2px;
left: 2px;
width: 20px;
height: 20px;
}
.schedule-modal-form .schedule-form-section .modal-toggle input[type="checkbox"]:checked::after {
transform: translateX(20px);
}
.schedule-modal-form .schedule-form-section .modal-toggle > span {
grid-column: 1;
grid-row: 1;
display: grid !important;
gap: 5px;
min-width: 0;
max-width: 100%;
color: rgba(231, 239, 255, .9) !important;
font: inherit !important;
letter-spacing: 0 !important;
text-transform: none !important;
}
.schedule-modal-form .schedule-form-section .modal-toggle strong {
color: #f7fbff;
font: 800 13px/1.2 var(--display-font);
letter-spacing: 0;
text-transform: none;
}
.schedule-modal-form .schedule-form-section .modal-toggle small {
display: block;
color: rgba(205, 218, 238, .66);
font: 600 11px/1.45 var(--body-font);
letter-spacing: 0;
text-transform: none;
overflow-wrap: anywhere;
}
.day-chip-grid {
display: flex;
flex-wrap: wrap;
@@ -1068,13 +1214,20 @@ function relativeDue(value) {
.day-chip-grid button,
.schedule-preview-grid span {
min-height: 36px;
padding: 0 13px;
min-height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
max-width: 100%;
padding: 0 15px;
border: 1px solid rgba(139, 224, 255, .16);
border-radius: 999px;
color: rgba(232, 240, 255, .74);
background: rgba(8, 13, 29, .72);
font-weight: 900;
font: 900 13px/1 var(--display-font);
letter-spacing: 0;
white-space: normal;
text-align: center;
}
.day-chip-grid button.active {
@@ -1085,8 +1238,30 @@ function relativeDue(value) {
.schedule-preview-grid {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 14px;
min-width: 0;
min-height: 74px;
padding: 17px 16px 18px;
}
.schedule-modal-form .modal-actions {
position: sticky;
bottom: 0;
z-index: 3;
flex: 0 0 auto;
margin: 4px -24px 0;
padding: 16px 24px 18px;
border-top: 1px solid rgba(156, 199, 232, .14);
background:
linear-gradient(180deg, rgba(7, 12, 25, .58), rgba(3, 8, 18, .96) 55%),
rgba(4, 10, 22, .94);
backdrop-filter: blur(18px);
}
.schedule-modal-form .modal-actions .ghost-button,
.schedule-modal-form .modal-actions .primary-action {
min-width: 132px;
}
@media (max-width: 1100px) {
@@ -1131,16 +1306,120 @@ function relativeDue(value) {
}
.schedule-form-section .deployment-section-toggle {
grid-template-columns: 1fr auto;
}
.schedule-form-section .deployment-section-toggle > span:nth-child(2) {
grid-column: 1 / -1;
white-space: normal;
grid-template-columns: 38px minmax(0, 1fr) 28px;
}
.schedule-form-section .form-grid.two {
grid-template-columns: 1fr;
}
:global(.form-modal:has(.schedule-modal-form)) {
width: calc(100dvw - 18px);
max-width: calc(100dvw - 18px);
height: calc(100dvh - 18px);
max-height: calc(100dvh - 18px);
}
:global(.modal-backdrop:has(.schedule-modal-form)) {
padding: 9px;
}
:global(.form-modal:has(.schedule-modal-form) > header) {
padding: 16px;
}
.schedule-modal-form {
padding: 14px 14px 0;
}
.schedule-modal-form .modal-actions {
margin-inline: -14px;
padding: 12px 14px 14px;
}
.schedule-modal-form .modal-actions .ghost-button,
.schedule-modal-form .modal-actions .primary-action {
flex: 1 1 0;
min-width: 0;
}
}
@media (max-width: 340px) {
:global(.form-modal:has(.schedule-modal-form)) {
width: calc(100dvw - 10px);
max-width: calc(100dvw - 10px);
height: calc(100dvh - 10px);
max-height: calc(100dvh - 10px);
border-radius: 18px;
}
:global(.modal-backdrop:has(.schedule-modal-form)) {
padding: 5px;
}
:global(.form-modal:has(.schedule-modal-form) > header) {
padding: 14px 14px 12px;
}
.schedule-modal-form {
gap: 10px;
padding: 10px 10px 0;
}
.schedule-form-section {
border-radius: 17px;
}
.schedule-form-section .deployment-section-toggle {
grid-template-columns: 34px minmax(0, 1fr) 24px;
gap: 8px;
min-height: 58px;
padding: 11px 12px;
}
.schedule-section-icon {
flex-basis: 34px;
width: 34px;
height: 34px;
min-width: 34px;
min-height: 34px;
}
.schedule-section-icon i {
width: 16px;
height: 16px;
font-size: 16px;
}
.schedule-section-copy strong {
font-size: 13px;
}
.schedule-section-copy small {
font-size: 11px;
}
.schedule-form-section .deployment-section-toggle > i:last-child {
width: 24px;
height: 24px;
font-size: 16px;
}
.schedule-form-section .form-grid {
gap: 12px;
padding: 12px;
}
.schedule-modal-form .schedule-form-section .modal-toggle {
grid-template-columns: minmax(0, 1fr) 42px;
gap: 11px;
min-height: 78px;
padding: 11px 12px !important;
}
.schedule-modal-form .modal-actions {
margin-inline: -10px;
padding: 10px;
}
}
</style>

View File

@@ -89,11 +89,11 @@
</template>
<template #cell-actions="{ row }">
<div class="table-actions">
<button v-if="row.canRunNow" class="ghost-button compact" type="button" :disabled="loading" @click="$emit('run', row.id)">
<i class="mdi mdi-play-circle-outline" aria-hidden="true"></i>Run now
<button v-if="row.canRunNow" class="ghost-button compact icon-button" type="button" :disabled="loading" aria-label="Run job now" title="Run job now" @click="$emit('run', row.id)">
<i class="mdi mdi-play-circle-outline" aria-hidden="true"></i>
</button>
<button v-if="row.canCancel" class="ghost-button compact danger-text" type="button" :disabled="loading" @click="openCancel(row)">
<i class="mdi mdi-cancel" aria-hidden="true"></i>Cancel
<button v-if="row.canCancel" class="ghost-button compact icon-button danger-text" type="button" :disabled="loading" aria-label="Cancel job" title="Cancel job" @click="openCancel(row)">
<i class="mdi mdi-cancel" aria-hidden="true"></i>
</button>
<span v-if="!row.canRunNow && !row.canCancel" class="status-pill neutral">view only</span>
</div>

820
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,7 +27,6 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"monaco-editor": "^0.53.0",
"mstsc.js": "^0.2.4",
"multer": "^2.2.0",
"nanoid": "^5.1.6",
"path": "^0.12.7",

15
pw_check2.mjs Normal file
View File

@@ -0,0 +1,15 @@
import { chromium } from 'playwright'
const browser = await chromium.launch()
const page = await browser.newPage()
const failed = []
page.on('response', (res) => { if (res.status() === 401) failed.push(res.url()) })
await page.goto('http://localhost:5173/login')
await page.fill('#email', 'admin@stableplace.local')
await page.fill('#password', 'ChangeMeNow!123')
await page.click('button[type="submit"]')
await page.waitForURL('http://localhost:5173/', { timeout: 10000 })
await page.waitForTimeout(1500)
console.log('401s:', JSON.stringify(failed, null, 2))
await browser.close()

31
pw_check_stableplace.mjs Normal file
View File

@@ -0,0 +1,31 @@
import { chromium } from 'playwright'
const OUT = '/tmp/claude-1000/-home-matthewp-Code-StablePlace-app/cd68dc7a-bd5f-43ab-9404-06ac41bed912/scratchpad'
const browser = await chromium.launch()
const page = await browser.newPage()
const consoleErrors = []
page.on('console', (msg) => { if (msg.type() === 'error') consoleErrors.push(msg.text()) })
page.on('pageerror', (err) => consoleErrors.push('pageerror: ' + err.message))
await page.goto('http://localhost:5173/login')
await page.fill('#email', 'admin@stableplace.local')
await page.fill('#password', 'ChangeMeNow!123')
await page.click('button[type="submit"]')
await page.waitForURL('http://localhost:5173/', { timeout: 10000 })
await page.waitForTimeout(1000)
await page.goto('http://localhost:5173/settings')
await page.waitForTimeout(800)
const notifBtn = page.locator('text=Notifications').first()
if (await notifBtn.count()) {
await notifBtn.click()
await page.waitForTimeout(800)
}
await page.screenshot({ path: `${OUT}/settings-notifications.png`, fullPage: true })
await page.goto('http://localhost:5173/profile')
await page.waitForTimeout(1000)
await page.screenshot({ path: `${OUT}/profile.png`, fullPage: true })
console.log('CONSOLE_ERRORS:', JSON.stringify(consoleErrors, null, 2))
await browser.close()

View File

@@ -40,6 +40,16 @@ export const config = {
defaultAdminName: process.env.DEFAULT_ADMIN_NAME || 'POSH Admin',
powershellBin: process.env.POWERSHELL_BIN || 'pwsh',
allowScriptExecution: (process.env.ALLOW_SCRIPT_EXECUTION || 'true').toLowerCase() === 'true',
myrtille: {
enabled: (process.env.MYRTILLE_ENABLED || 'false').toLowerCase() === 'true',
gatewayUrl: process.env.MYRTILLE_GATEWAY_URL || '',
usePasswordHash: (process.env.MYRTILLE_USE_PASSWORD_HASH || 'true').toLowerCase() === 'true',
allowPlainPassword: (process.env.MYRTILLE_ALLOW_PLAIN_PASSWORD || 'false').toLowerCase() === 'true',
hashEndpoint: process.env.MYRTILLE_HASH_ENDPOINT || 'GetHash.aspx',
defaultWidth: Number(process.env.MYRTILLE_DEFAULT_WIDTH || 1280),
defaultHeight: Number(process.env.MYRTILLE_DEFAULT_HEIGHT || 800),
requestTimeoutMs: Number(process.env.MYRTILLE_REQUEST_TIMEOUT_MS || 15000)
},
vcenter: {
enabled: (process.env.VCENTER_ENABLED || 'false').toLowerCase() === 'true',
baseUrl: process.env.VCENTER_BASE_URL || '',

View File

@@ -33,9 +33,9 @@ export function destroy(req, res) {
res.status(204).end();
}
export function createRdpSession(req, res) {
export async function createRdpSession(req, res) {
try {
res.status(201).json(createRdpLaunchSession(req.params.id, req.user.id));
res.status(201).json(await createRdpLaunchSession(req.params.id, req.user.id));
} catch (error) {
res.status(error.statusCode || 400).json({ error: error.message });
}

View File

@@ -1,5 +1,8 @@
import { analyzeInstaller, listInstallerTechnologies } from '../services/installerIntelService.js';
import { buildDetectionRule } from '../services/detectionRuleService.js';
import { buildRecipeArtifacts, getRecipe, searchRecipes } from '../services/recipeService.js';
import { createPsadtProfile, createIntuneDeployment } from '../models/psadtModel.js';
import { createApplication } from '../models/applicationModel.js';
export function installerTypes(req, res) {
res.json(listInstallerTechnologies());
@@ -18,3 +21,31 @@ export function detection(req, res) {
res.status(400).json({ error: error.message });
}
}
export function recipes(req, res) {
res.json(searchRecipes(req.query?.q || ''));
}
export function applyRecipe(req, res) {
const recipe = getRecipe(req.params.id);
if (!recipe) return res.status(404).json({ error: 'Recipe not found' });
try {
const artifacts = buildRecipeArtifacts(recipe, {
visibility: req.body?.visibility || 'personal',
groupId: req.body?.groupId || null
});
const userId = req.user.id;
const profile = createPsadtProfile(artifacts.profile, userId);
const application = artifacts.application ? createApplication(artifacts.application, userId) : null;
const deployment = createIntuneDeployment({
...artifacts.deployment,
profileId: profile.id,
applicationId: application?.id || null
}, userId);
res.status(201).json({ recipeId: recipe.id, profile, deployment, application });
} catch (error) {
res.status(400).json({ error: error.message });
}
}

View File

@@ -26,10 +26,13 @@ import { computeDrift } from '../services/intuneDriftService.js';
import { canPublish } from '../services/graphRbac.js';
import { cloneForNextVersion, setRingIntent } from '../services/intunePromotionService.js';
import { parseIntunewin } from '../services/intunewinParser.js';
import { createAssetFromUpload, getAssetFile } from '../models/assetModel.js';
import { createAssetFromUpload, getAssetFile, listLinkedAssets } from '../models/assetModel.js';
import { validatePsadtScript, validationRuleCatalog } from '../services/psadtValidator.js';
import { planPsadtMigration } from '../services/psadtMigrationService.js';
import { basenameAny, path } from '../utils/pathUtils.js';
import { ENTRY_SCRIPT_NAME, buildZip, planPackageLayout, resolveSetupFile } from '../services/packageBuilder.js';
import { renderDatasheet } from '../services/packageDocService.js';
import { loadApplication } from '../models/applicationModel.js';
import { basenameAny, path, sanitizeFileName } from '../utils/pathUtils.js';
export function catalog(req, res) {
res.json(getPsadtCatalog());
@@ -434,6 +437,24 @@ export function intuneGraphAudit(req, res) {
res.json(listGraphAudit({ deploymentId: deployment.id }));
}
// Auto-generated package datasheet: render a deployment (plus its linked PSADT
// profile and catalog application) to Markdown or HTML. Pure render lives in
// packageDocService. ?format=md|html (default md); ?download=1 forces a file.
export function intuneDatasheet(req, res) {
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
const profile = deployment.profileId ? loadPsadtProfile(deployment.profileId, req.user.id) : null;
const application = deployment.applicationId ? loadApplication(deployment.applicationId, req.user.id) : null;
const doc = renderDatasheet({ deployment, profile, application }, { format: req.query?.format });
const fileName = `${sanitizeFileName(doc.title || 'package', 'package').replace(/\.[^.]+$/, '')}-datasheet.${doc.extension}`;
res.type(doc.contentType);
if (req.query?.download === '1' || req.query?.download === 'true') {
res.set('Content-Disposition', `attachment; filename="${fileName}"`);
}
res.send(doc.content);
}
export function index(req, res) {
res.json(listPsadtProfiles(req.user.id));
}
@@ -467,6 +488,89 @@ export function render(req, res) {
res.json(rendered);
}
// Assemble a complete, portable PSADT v4 package from a profile: render the
// Invoke-AppDeployToolkit.ps1 entry script, lay out Files/SupportFiles/Assets
// from the profile's linked Asset Library items, zip it, and ingest the zip into
// the Asset Library. With { build: true } also hand the assembled tree to the
// .intunewin builder. Pure layout + zip logic lives in packageBuilder.
export async function packageProfile(req, res) {
const rendered = renderPsadtProfile(req.params.id, req.user.id);
if (!rendered) return res.status(404).json({ error: 'PSADT profile not found' });
const links = listLinkedAssets('psadt_profile', req.params.id, req.user.id);
const layout = planPackageLayout(links);
const warnings = [...layout.warnings];
const zipEntries = [
{ path: ENTRY_SCRIPT_NAME, data: Buffer.from(rendered.script, 'utf8') },
...layout.directories.map((dir) => ({ path: dir, isDirectory: true }))
];
for (const asset of layout.assets) {
const fileRef = getAssetFile(asset.assetId, req.user.id);
if (!fileRef) {
warnings.push(`Asset "${asset.name || asset.assetId}" is no longer accessible and was skipped.`);
continue;
}
zipEntries.push({ path: asset.packagePath, data: fs.readFileSync(fileRef.filePath) });
}
const includedFiles = zipEntries.filter((entry) => !entry.isDirectory && entry.path !== ENTRY_SCRIPT_NAME);
const stageDir = path.join(config.toolsDir, '_package', rendered.profile.id);
fs.rmSync(stageDir, { recursive: true, force: true });
fs.mkdirSync(stageDir, { recursive: true });
const baseName = sanitizeFileName(`${rendered.profile.name || 'psadt'}-package`, 'psadt-package').replace(/\.[^.]+$/, '');
const zipPath = path.join(stageDir, `${baseName}.zip`);
const zipBuffer = buildZip(zipEntries);
fs.writeFileSync(zipPath, zipBuffer);
const asset = createAssetFromUpload(
{ path: zipPath, originalname: `${baseName}.zip`, mimetype: 'application/zip', size: zipBuffer.length },
{ name: `${rendered.profile.name} package`, visibility: rendered.profile.visibility, groupId: rendered.profile.groupId, packagePath: `${baseName}.zip` },
req.user.id
);
const manifest = {
script: ENTRY_SCRIPT_NAME,
directories: layout.directories,
files: layout.assets.map((item) => ({ packagePath: item.packagePath, role: item.role, name: item.name })),
fileCount: includedFiles.length
};
let intunewin = null;
if (req.body?.build) {
const setupFile = resolveSetupFile(layout, req.body?.setupFile);
const availability = builderAvailability();
if (!setupFile) {
warnings.push('Skipped .intunewin build: no installer in the package. Link an installer asset or pass setupFile.');
} else if (!availability.available) {
warnings.push(`Skipped .intunewin build: ${availability.reason}`);
} else {
const sourceFolder = path.join(stageDir, 'source');
for (const entry of zipEntries) {
const dest = path.join(sourceFolder, entry.path);
if (entry.isDirectory) { fs.mkdirSync(dest, { recursive: true }); continue; }
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, entry.data);
}
try {
const { outputFile } = await buildIntuneWin({ sourceFolder, setupFile, outputDir: path.join(stageDir, '_out') });
const stats = fs.statSync(outputFile);
const outName = basenameAny(outputFile, 'package.intunewin');
intunewin = createAssetFromUpload(
{ path: outputFile, originalname: outName, mimetype: 'application/octet-stream', size: stats.size },
{ name: `${rendered.profile.name} intunewin`, visibility: rendered.profile.visibility, groupId: rendered.profile.groupId, packagePath: outName },
req.user.id
);
manifest.setupFile = setupFile;
} catch (error) {
warnings.push(`.intunewin build failed: ${error.message}`);
}
}
}
res.status(201).json({ asset, manifest, warnings, intunewin, builder: builderAvailability() });
}
export function rules(req, res) {
res.json(validationRuleCatalog());
}

View File

@@ -64,7 +64,8 @@ const operationsArticles = [
'Create credentials in Credential Vault first, then attach them to hosts. Secrets are encrypted using AES-256-GCM.',
'Hosts support WinRM, SSH, local, and API transport metadata. PowerShell remoting and firewall prerequisites must be configured outside POSHManager.',
'Each host tracks an OS type of Windows, Linux, or Other. vCenter imports infer this from VMware Tools when possible; manual hosts should set it during add/edit.',
'Windows hosts with an assigned username/password Credential Vault entry show an RDP icon. The icon opens a short-lived /rdp/:token URL in a new tab and renders a mstsc.js browser RDP canvas while the API keeps the real password server-side.',
'Windows hosts with an assigned username/password Credential Vault entry show an RDP icon. The icon asks the API for a Myrtille launch URL and opens that gateway in a new tab.',
'Configure myrtille_enabled and myrtille_gateway_url in Configuration / Integrations or pin them with MYRTILLE_ENABLED and MYRTILLE_GATEWAY_URL. Password-hash mode is enabled by default so the browser receives a Myrtille passwordHash instead of the plaintext vault secret.',
'When Linux targets are selected, POSHManager checks scripts for common Windows-only PowerShell patterns such as registry providers, C:\\ paths, WMI/Win32 classes, Windows executables, and risky alias usage.'
]),
section('Host Groups and VMware sources', [
@@ -96,6 +97,11 @@ const operationsArticles = [
'Users & Groups is a dedicated administration menu item. Add users and groups through modal create flows.',
'Scripts, RunPlans, variables, PSADT profiles, and Intune deployment plans can use personal, shared, or group visibility.'
]),
section('Profile and themes', [
'Open the avatar menu in the top bar to edit profile details, avatar URL, password settings, and theme preference.',
'Available themes include Dashtreme Pulse, Green Screen, Aurora Glass, Greyscale Pro, and macOS Desktop. The macOS mode uses desktop-style frosted chrome; Greyscale Pro uses neutral high-contrast surfaces.',
'Dense data tables use icon-only row actions with accessible labels and hover tooltips, while page-level create/save commands keep text labels.'
]),
section('Configuration', [
'Config is grouped into collapsible sections for Deployment, Authentication, Execution, Runtime, and Other settings, with Microsoft Graph Intune environments managed in the Config / Intune panel.',
'Settings can be controlled through the UI and environment variables for Docker deployment. Trusted origins, server FQDN, Entra fields, PowerShell binary, and script execution switch are key operational settings.'
@@ -108,6 +114,7 @@ const apiArticles = [
apiExample('Post', '/api/auth/login', 'Authenticate with local credentials and receive a JWT token.', '@{ email = "admin@posh.local"; password = "change-me-now" }'),
apiExample('Get', '/api/auth/me', 'Return the current authenticated user.'),
apiExample('Put', '/api/auth/profile', 'Update display name, profile fields, and avatar URL.', '@{ displayName = "POSH Admin"; email = "admin@posh.local"; jobTitle = "Platform Administrator"; phone = ""; timezone = "America/Chicago"; avatarUrl = "" }'),
apiExample('Put', '/api/auth/theme', 'Update the current user theme. Supported values include dashtreme, terminal, aurora, greyscale, and macos.', '@{ theme = "macos" }'),
apiExample('Get', '/api/bootstrap', 'Return dashboard summary, settings payload, and Entra status.'),
section('Notes', [
'Use the Bearer token returned by /api/auth/login for every protected request.',
@@ -135,7 +142,7 @@ const apiArticles = [
article('api-hosts-credentials-runplans', 'API', 'Hosts, Credentials, RunPlans, Jobs, And Logs API', 'Manage execution targets and inspect results through the API.', [
apiExample('Post', '/api/credentials', 'Create an encrypted username/password credential.', '@{ name = "WinRM Admin"; kind = "username_password"; username = "CONTOSO\\svc-posh"; secret = "change-me"; visibility = "personal" }'),
apiExample('Post', '/api/hosts', 'Create a managed host.', '@{ name = "APP01"; address = "app01.contoso.local"; fqdn = "app01.contoso.local"; osFamily = "windows"; transport = "winrm"; port = 5986; credentialId = $null; tags = @("prod","web"); notes = "" }'),
apiExample('Post', '/api/hosts/hst_123/rdp-session', 'Create a short-lived browser RDP launch URL for a visible Windows host with an assigned visible username/password credential.', '$session = Invoke-RestMethod -Method Post -Uri "$base/api/hosts/hst_123/rdp-session" -Headers $headers\nStart-Process "$base$($session.url)"'),
apiExample('Post', '/api/hosts/hst_123/rdp-session', 'Create a Myrtille browser RDP launch URL for a visible Windows host with an assigned visible username/password credential.', '$session = Invoke-RestMethod -Method Post -Uri "$base/api/hosts/hst_123/rdp-session" -Headers $headers\nStart-Process $session.url'),
apiExample('Post', '/api/scripts/scr_123/compatibility', 'Analyze a script against direct host and Host Group targets before executing against Linux hosts.', '@{ hostIds = @("hst_linux_01"); hostGroupIds = @("hg_mixed_targets") }'),
apiExample('Get', '/api/runplans/rp_123/compatibility', 'Analyze a RunPlan script against its current target OS mix before execution.'),
apiExample('Post', '/api/runplans', 'Create a RunPlan. hostIds should contain existing host ids.', '@{ name = "Patch Validation"; description = "Run validation script"; scriptId = "scr_123"; visibility = "shared"; parallel = $true; hostIds = @("hst_123") }'),
@@ -163,6 +170,8 @@ const apiArticles = [
apiExample('Post', '/api/psadt/validate', 'Validate raw PSADT script content.', '@{ platform = "intune"; content = "Invoke-AppDeployToolkit.exe -DeploymentType Install" }'),
apiExample('Post', '/api/psadt/migration/plan', 'Plan a legacy script migration to PSADT 4.2.0.', `@{ targetVersion = "4.2.0"; content = "Deploy-Application.ps1\`nExecute-Process -Path setup.exe\`n$appName = 'Legacy App'" }`),
apiExample('Post', '/api/psadt/profiles', 'Create a reusable PSADT deployment profile.', '@{ name = "Edge Enterprise"; appVendor = "Microsoft"; appName = "Edge"; appVersion = "126.0"; appArch = "x64"; appLang = "EN"; appRevision = "01"; templateVersion = "v4"; deploymentType = "Install"; deployMode = "Auto"; requireAdmin = $true; zeroConfig = $false; suppressReboot = $false; closeProcesses = @(); installTasks = @(@{ type = "exe"; phase = "Install"; filePath = "setup.exe"; arguments = "/S"; secureArguments = $false }); uiPlan = @{ welcome = $true }; configPlan = @{}; admxPlan = @{}; visibility = "shared" }'),
apiExample('Post', '/api/psadt/profiles/psadt_123/package', 'Assemble a portable PSADT v4 package: render Invoke-AppDeployToolkit.ps1, lay out Files/SupportFiles/Assets from the profile\'s linked assets, zip it, and ingest the zip into the Asset Library. Pass build = $true to also produce a .intunewin from the assembled tree (Windows + IntuneWinAppUtil or INTUNEWIN_BUILD_COMMAND). Download the returned asset via /api/assets/{id}/download.', '@{ build = $true; setupFile = "Files/setup.exe" }'),
apiExample('Get', '/api/psadt/intune/deployments/intune_123/datasheet?format=html', 'Auto-generate a package datasheet (Markdown or self-contained HTML) for a deployment: metadata, install/uninstall commands, detection, return codes, assignments, requirements, and source/version. Use format=md or format=html; add download=1 to return it as a file attachment.'),
apiExample('Post', '/api/psadt/intune/deployments', 'Create an Intune Publishing Wizard plan.', '@{ name = "Edge Pilot"; sourceFolder = "C:\\Packages\\Edge\\PSADT"; intunewinFile = "Edge.intunewin"; commandStyle = "v4"; installBehavior = "system"; restartBehavior = "return-code"; uiMode = "native-v4"; requirements = @{ architecture = "x64"; minOs = "Windows 10 22H2"; diskSpaceMb = 512; runAs32Bit = $false }; installCommand = "Invoke-AppDeployToolkit.exe -DeploymentType Install"; uninstallCommand = "Invoke-AppDeployToolkit.exe -DeploymentType Uninstall"; detectionType = "custom-script"; detectionRule = "Write-Output detected"; returnCodes = @(@{ code = 0; type = "success"; meaning = "OK" }, @{ code = 1602; type = "retry"; meaning = "Deferred" }, @{ code = 3010; type = "softReboot"; meaning = "Reboot" }); assignments = @(@{ ring = "Pilot"; intent = "available"; groupName = "IT Pilot"; notes = "Validate" }); status = "ready"; visibility = "shared" }')
], ['api', 'psadt', 'intune', 'migration'])
,

View File

@@ -0,0 +1,300 @@
// Curated application recipe library. Each entry holds the installer
// technology, an example installer file name, silent install args, a detection
// footprint, and (when one exists) the winget id for update tracking.
//
// `installerType` must match an INSTALLER_TECHNOLOGIES id.
// `detection` is passed verbatim to detectionRuleService.buildDetectionRule.
// Args with <angle-bracket> placeholders are intentional refine-me prompts.
export const RECIPES = [
// --- Browsers --------------------------------------------------------------
{
id: 'google-chrome',
vendor: 'Google', name: 'Chrome', category: 'Browser',
installerType: 'msi', fileName: 'googlechromestandaloneenterprise64.msi', arch: 'x64',
installArgs: '/qn /norestart',
closeProcesses: [{ name: 'chrome', description: 'Google Chrome' }],
detection: { type: 'file', path: '%ProgramFiles%\\Google\\Chrome\\Application\\chrome.exe' },
wingetId: 'Google.Chrome', homepage: 'https://chromeenterprise.google/browser/download/'
},
{
id: 'mozilla-firefox',
vendor: 'Mozilla', name: 'Firefox', category: 'Browser',
installerType: 'nsis', fileName: 'Firefox Setup.exe', arch: 'x64',
installArgs: '/S',
closeProcesses: [{ name: 'firefox', description: 'Mozilla Firefox' }],
detection: { type: 'file', path: '%ProgramFiles%\\Mozilla Firefox\\firefox.exe' },
wingetId: 'Mozilla.Firefox', homepage: 'https://www.mozilla.org/firefox/all/'
},
{
id: 'microsoft-edge',
vendor: 'Microsoft', name: 'Edge', category: 'Browser',
installerType: 'msi', fileName: 'MicrosoftEdgeEnterpriseX64.msi', arch: 'x64',
installArgs: '/qn /norestart',
closeProcesses: [{ name: 'msedge', description: 'Microsoft Edge' }],
detection: { type: 'file', path: '%ProgramFiles(x86)%\\Microsoft\\Edge\\Application\\msedge.exe' },
wingetId: 'Microsoft.Edge', homepage: 'https://www.microsoft.com/edge/business/download'
},
{
id: 'brave',
vendor: 'Brave', name: 'Brave Browser', category: 'Browser',
installerType: 'nsis', fileName: 'BraveBrowserStandaloneSetup.exe', arch: 'x64',
installArgs: '/silent /install',
detection: { type: 'file', path: '%ProgramFiles%\\BraveSoftware\\Brave-Browser\\Application\\brave.exe' },
wingetId: 'Brave.Brave', homepage: 'https://brave.com/download/'
},
// --- Productivity & Office -------------------------------------------------
{
id: 'adobe-reader',
vendor: 'Adobe', name: 'Acrobat Reader', category: 'Productivity',
installerType: 'msi', fileName: 'AcroRead.msi', arch: 'x64',
installArgs: '/qn /norestart',
closeProcesses: [{ name: 'Acrobat', description: 'Adobe Acrobat' }, { name: 'AcroRd32', description: 'Adobe Reader' }],
detection: { type: 'file', path: '%ProgramFiles%\\Adobe\\Acrobat DC\\Acrobat\\Acrobat.exe' },
wingetId: 'Adobe.Acrobat.Reader.64-bit', homepage: 'https://get.adobe.com/reader/enterprise/'
},
{
id: 'microsoft-365-apps',
vendor: 'Microsoft', name: 'Microsoft 365 Apps (Word/Excel/PowerPoint)', category: 'Productivity',
installerType: 'exe', fileName: 'setup.exe', arch: 'x64',
// Office is deployed with the Office Deployment Tool + a configuration.xml.
installArgs: '/configure configuration.xml',
closeProcesses: [{ name: 'winword', description: 'Word' }, { name: 'excel', description: 'Excel' }, { name: 'powerpnt', description: 'PowerPoint' }, { name: 'outlook', description: 'Outlook' }],
detection: { type: 'file', path: '%ProgramFiles%\\Microsoft Office\\root\\Office16\\WINWORD.EXE' },
wingetId: 'Microsoft.Office', homepage: 'https://www.microsoft.com/microsoft-365/enterprise'
},
{
id: 'libreoffice',
vendor: 'The Document Foundation', name: 'LibreOffice', category: 'Productivity',
installerType: 'msi', fileName: 'LibreOffice.msi', arch: 'x64',
installArgs: '/qn /norestart',
detection: { type: 'file', path: '%ProgramFiles%\\LibreOffice\\program\\soffice.exe' },
wingetId: 'TheDocumentFoundation.LibreOffice', homepage: 'https://www.libreoffice.org/download/download/'
},
// --- Communication ---------------------------------------------------------
{
id: 'zoom',
vendor: 'Zoom', name: 'Zoom Workplace', category: 'Communication',
installerType: 'msi', fileName: 'ZoomInstallerFull.msi', arch: 'x64',
installArgs: '/qn /norestart ZoomAutoUpdate=true',
closeProcesses: [{ name: 'Zoom', description: 'Zoom' }],
detection: { type: 'file', path: '%ProgramFiles%\\Zoom\\bin\\Zoom.exe' },
wingetId: 'Zoom.Zoom', homepage: 'https://zoom.us/download'
},
{
id: 'microsoft-teams',
vendor: 'Microsoft', name: 'Teams (new)', category: 'Communication',
installerType: 'msix', fileName: 'MSTeams-x64.msix', arch: 'x64',
installArgs: '',
detection: { type: 'file', path: '%ProgramFiles%\\WindowsApps\\MSTeams_*\\ms-teams.exe' },
wingetId: 'Microsoft.Teams', homepage: 'https://www.microsoft.com/microsoft-teams/download-app'
},
{
id: 'slack',
vendor: 'Slack', name: 'Slack', category: 'Communication',
installerType: 'msi', fileName: 'slack-standalone.msi', arch: 'x64',
installArgs: '/qn /norestart',
closeProcesses: [{ name: 'slack', description: 'Slack' }],
detection: { type: 'registry', keyPath: 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{Slack}', valueName: 'DisplayName' },
wingetId: 'SlackTechnologies.Slack', homepage: 'https://slack.com/downloads/windows'
},
{
id: 'cisco-webex',
vendor: 'Cisco', name: 'Webex App', category: 'Communication',
installerType: 'msi', fileName: 'Webex.msi', arch: 'x64',
installArgs: '/qn /norestart ALLUSERS=1 AUTOUPGRADEENABLED=1',
detection: { type: 'file', path: '%ProgramFiles(x86)%\\Cisco Spark\\CiscoCollabHost.exe' },
wingetId: 'Cisco.Webex', homepage: 'https://www.webex.com/downloads.html'
},
// --- Media -----------------------------------------------------------------
{
id: 'vlc',
vendor: 'VideoLAN', name: 'VLC media player', category: 'Media',
installerType: 'nsis', fileName: 'vlc-win64.exe', arch: 'x64',
installArgs: '/S',
closeProcesses: [{ name: 'vlc', description: 'VLC media player' }],
detection: { type: 'file', path: '%ProgramFiles%\\VideoLAN\\VLC\\vlc.exe' },
wingetId: 'VideoLAN.VLC', homepage: 'https://www.videolan.org/vlc/'
},
// --- Utilities -------------------------------------------------------------
{
id: '7zip',
vendor: '7-Zip', name: '7-Zip', category: 'Utility',
installerType: 'msi', fileName: '7z-x64.msi', arch: 'x64',
installArgs: '/qn /norestart',
detection: { type: 'file', path: '%ProgramFiles%\\7-Zip\\7z.exe' },
wingetId: '7zip.7zip', homepage: 'https://www.7-zip.org/download.html'
},
{
id: 'winrar',
vendor: 'RARLAB', name: 'WinRAR', category: 'Utility',
installerType: 'exe', fileName: 'winrar-x64.exe', arch: 'x64',
installArgs: '/S',
detection: { type: 'file', path: '%ProgramFiles%\\WinRAR\\WinRAR.exe' },
wingetId: 'RARLab.WinRAR', homepage: 'https://www.win-rar.com/download.html'
},
{
id: 'notepad-plus-plus',
vendor: 'Notepad++', name: 'Notepad++', category: 'Utility',
installerType: 'nsis', fileName: 'npp.Installer.x64.exe', arch: 'x64',
installArgs: '/S',
closeProcesses: [{ name: 'notepad++', description: 'Notepad++' }],
detection: { type: 'file', path: '%ProgramFiles%\\Notepad++\\notepad++.exe' },
wingetId: 'Notepad++.Notepad++', homepage: 'https://notepad-plus-plus.org/downloads/'
},
{
id: 'greenshot',
vendor: 'Greenshot', name: 'Greenshot', category: 'Utility',
installerType: 'inno', fileName: 'Greenshot-INSTALLER.exe', arch: 'x64',
installArgs: '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-',
detection: { type: 'file', path: '%ProgramFiles%\\Greenshot\\Greenshot.exe' },
wingetId: 'Greenshot.Greenshot', homepage: 'https://getgreenshot.org/downloads/'
},
{
id: 'putty',
vendor: 'Simon Tatham', name: 'PuTTY', category: 'Utility',
installerType: 'msi', fileName: 'putty-64bit-installer.msi', arch: 'x64',
installArgs: '/qn /norestart',
detection: { type: 'file', path: '%ProgramFiles%\\PuTTY\\putty.exe' },
wingetId: 'PuTTY.PuTTY', homepage: 'https://www.putty.org/'
},
{
id: 'winscp',
vendor: 'WinSCP', name: 'WinSCP', category: 'Utility',
installerType: 'inno', fileName: 'WinSCP-Setup.exe', arch: 'x64',
installArgs: '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /ALLUSERS',
detection: { type: 'file', path: '%ProgramFiles(x86)%\\WinSCP\\WinSCP.exe' },
wingetId: 'WinSCP.WinSCP', homepage: 'https://winscp.net/eng/download.php'
},
// --- Developer -------------------------------------------------------------
{
id: 'git',
vendor: 'Git', name: 'Git for Windows', category: 'Developer',
installerType: 'inno', fileName: 'Git-64-bit.exe', arch: 'x64',
installArgs: '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-',
detection: { type: 'file', path: '%ProgramFiles%\\Git\\bin\\git.exe' },
wingetId: 'Git.Git', homepage: 'https://git-scm.com/download/win'
},
{
id: 'vscode',
vendor: 'Microsoft', name: 'Visual Studio Code', category: 'Developer',
installerType: 'inno', fileName: 'VSCodeSetup-x64.exe', arch: 'x64',
installArgs: '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS=!runcode',
closeProcesses: [{ name: 'Code', description: 'Visual Studio Code' }],
detection: { type: 'file', path: '%ProgramFiles%\\Microsoft VS Code\\Code.exe' },
wingetId: 'Microsoft.VisualStudioCode', homepage: 'https://code.visualstudio.com/download'
},
{
id: 'powershell-7',
vendor: 'Microsoft', name: 'PowerShell 7', category: 'Developer',
installerType: 'msi', fileName: 'PowerShell-7-win-x64.msi', arch: 'x64',
installArgs: '/qn /norestart',
detection: { type: 'file', path: '%ProgramFiles%\\PowerShell\\7\\pwsh.exe' },
wingetId: 'Microsoft.PowerShell', homepage: 'https://github.com/PowerShell/PowerShell/releases'
},
{
id: 'nodejs',
vendor: 'OpenJS Foundation', name: 'Node.js LTS', category: 'Developer',
installerType: 'msi', fileName: 'node-x64.msi', arch: 'x64',
installArgs: '/qn /norestart',
detection: { type: 'file', path: '%ProgramFiles%\\nodejs\\node.exe' },
wingetId: 'OpenJS.NodeJS.LTS', homepage: 'https://nodejs.org/en/download'
},
{
id: 'python-3',
vendor: 'Python Software Foundation', name: 'Python 3', category: 'Developer',
installerType: 'exe', fileName: 'python-installer.exe', arch: 'x64',
installArgs: '/quiet InstallAllUsers=1 PrependPath=1 Include_test=0',
detection: { type: 'registry', keyPath: 'HKLM:\\SOFTWARE\\Python\\PythonCore' },
wingetId: 'Python.Python.3.12', homepage: 'https://www.python.org/downloads/windows/'
},
{
id: 'temurin-jdk',
vendor: 'Eclipse Adoptium', name: 'Temurin JDK 21', category: 'Developer',
installerType: 'msi', fileName: 'OpenJDK21-jdk_x64_windows.msi', arch: 'x64',
installArgs: '/qn /norestart ADDLOCAL=FeatureMain,FeatureEnvironment',
detection: { type: 'file', path: '%ProgramFiles%\\Eclipse Adoptium\\jdk-21\\bin\\java.exe' },
wingetId: 'EclipseAdoptium.Temurin.21.JDK', homepage: 'https://adoptium.net/temurin/releases/'
},
// --- Remote access & virtualization ---------------------------------------
{
id: 'vmware-workstation',
vendor: 'Broadcom (VMware)', name: 'Workstation Pro', category: 'Virtualization',
installerType: 'installshield', fileName: 'VMware-workstation.exe', arch: 'x64',
installArgs: '/s /v"/qn EULAS_AGREED=1 AUTOSOFTWAREUPDATE=0 REBOOT=ReallySuppress"',
detection: { type: 'file', path: '%ProgramFiles(x86)%\\VMware\\VMware Workstation\\vmware.exe' },
wingetId: 'VMware.WorkstationPro', homepage: 'https://www.vmware.com/products/workstation-pro.html'
},
{
id: 'realvnc-server',
vendor: 'RealVNC', name: 'VNC Server', category: 'Remote access',
installerType: 'msi', fileName: 'VNC-Server.msi', arch: 'x64',
installArgs: '/qn /norestart',
detection: { type: 'file', path: '%ProgramFiles%\\RealVNC\\VNC Server\\vncserver.exe' },
wingetId: 'RealVNC.VNCServer', homepage: 'https://www.realvnc.com/en/connect/download/vnc/'
},
{
id: 'teamviewer-host',
vendor: 'TeamViewer', name: 'TeamViewer Host', category: 'Remote access',
installerType: 'msi', fileName: 'TeamViewer_Host.msi', arch: 'x64',
installArgs: '/qn /norestart',
detection: { type: 'file', path: '%ProgramFiles%\\TeamViewer\\TeamViewer.exe' },
wingetId: 'TeamViewer.TeamViewer.Host', homepage: 'https://www.teamviewer.com/download/windows/'
},
{
id: 'anydesk',
vendor: 'AnyDesk', name: 'AnyDesk', category: 'Remote access',
installerType: 'exe', fileName: 'AnyDesk.exe', arch: 'x64',
installArgs: '--install "%ProgramFiles(x86)%\\AnyDesk" --start-with-win --silent',
detection: { type: 'file', path: '%ProgramFiles(x86)%\\AnyDesk\\AnyDesk.exe' },
wingetId: 'AnyDeskSoftwareGmbH.AnyDesk', homepage: 'https://anydesk.com/en/downloads/windows'
},
// --- Security & enterprise agents (no winget; vendor-managed updates) ------
{
id: 'crowdstrike-falcon',
vendor: 'CrowdStrike', name: 'Falcon Sensor', category: 'Security agent',
installerType: 'msi', fileName: 'WindowsSensor.exe', arch: 'x64',
installArgs: '/install /quiet /norestart CID=<your-falcon-cid>',
detection: { type: 'file', path: '%ProgramFiles%\\CrowdStrike\\CSFalconService.exe' },
wingetId: '', homepage: 'https://www.crowdstrike.com/'
},
{
id: 'cisco-umbrella',
vendor: 'Cisco', name: 'Umbrella Roaming Client', category: 'Security agent',
installerType: 'msi', fileName: 'Setup.msi', arch: 'x64',
installArgs: '/qn /norestart',
detection: { type: 'file', path: '%ProgramFiles(x86)%\\OpenDNS\\Umbrella Roaming Client\\ERAgent.exe' },
wingetId: '', homepage: 'https://umbrella.cisco.com/'
},
{
id: 'splunk-uf',
vendor: 'Splunk', name: 'Universal Forwarder', category: 'Monitoring agent',
installerType: 'msi', fileName: 'splunkforwarder-x64.msi', arch: 'x64',
installArgs: '/qn AGREETOLICENSE=Yes RECEIVING_INDEXER="<indexer-host>:9997" SPLUNKUSERNAME=admin',
detection: { type: 'file', path: '%ProgramFiles%\\SplunkUniversalForwarder\\bin\\splunk.exe' },
wingetId: '', homepage: 'https://www.splunk.com/en_us/download/universal-forwarder.html'
},
{
id: 'nessus-agent',
vendor: 'Tenable', name: 'Nessus Agent', category: 'Security agent',
installerType: 'msi', fileName: 'NessusAgent-x64.msi', arch: 'x64',
installArgs: '/qn NESSUS_GROUPS="<group>" NESSUS_SERVER="<manager>:8834" NESSUS_KEY="<linking-key>"',
detection: { type: 'file', path: '%ProgramFiles%\\Tenable\\Nessus Agent\\nessuscli.exe' },
wingetId: '', homepage: 'https://www.tenable.com/downloads/nessus-agents'
},
{
id: 'sap-gui',
vendor: 'SAP', name: 'SAP GUI for Windows', category: 'Enterprise',
installerType: 'exe', fileName: 'NwSapSetup.exe', arch: 'x64',
installArgs: '/silent /product="SAPGUI"',
detection: { type: 'file', path: '%ProgramFiles(x86)%\\SAP\\FrontEnd\\SAPGUI\\saplogon.exe' },
wingetId: '', homepage: 'https://support.sap.com/'
}
];

View File

@@ -11,7 +11,6 @@ import { startCatalogWorker } from './services/catalogWorker.js';
import { startPromotionWorker } from './services/promotionWorker.js';
import { startHostGroupWorker } from './services/hostGroupWorker.js';
import { startRunPlanScheduleWorker } from './services/runPlanScheduleWorker.js';
import { mountRdpGateway, startRdpGateway } from './services/rdpGatewayService.js';
// Fail fast in production rather than ship with well-known default secrets.
// These defaults are fine for local development but are publicly known, so a
@@ -56,16 +55,14 @@ app.use(cors({
app.use(express.json({ limit: config.jsonLimit }));
app.use(requestLogger);
app.use('/api', apiRoutes);
mountRdpGateway(app);
app.use((error, req, res, next) => {
logger.error('unhandled error', { error });
res.status(500).json({ error: 'Unexpected server error' });
});
const httpServer = app.listen(config.port, () => {
app.listen(config.port, () => {
logger.info(`POSHManager API listening on ${config.port}`);
startRdpGateway(httpServer);
startCatalogWorker();
startPromotionWorker();
startHostGroupWorker();

View File

@@ -221,6 +221,25 @@ export function deleteAsset(assetId, userId) {
return true;
}
// Reverse of listAssetLinks: every asset linked to a target (e.g. a PSADT
// profile) that the user can see, with the per-link package path/role used by
// the package builder to place it in the deployment tree.
export function listLinkedAssets(targetType, targetId, userId) {
return db.prepare(`
SELECT a.*, l.link_role AS link_role, l.package_path AS link_package_path
FROM asset_links l
INNER JOIN assets a ON a.id = l.asset_id
WHERE l.target_type = ? AND l.target_id = ? AND ${visibleClause('a')}
ORDER BY l.created_at
`).all(targetType, targetId, userId, userId).map((row) => ({
assetId: row.id,
originalName: row.original_name,
kind: row.kind,
linkRole: row.link_role,
packagePath: row.link_package_path || row.package_path || row.original_name
}));
}
export function listAssetLinks(assetId, userId) {
const asset = selectAssetById(assetId, userId);
if (!asset) return null;

View File

@@ -1,11 +1,11 @@
import { Router } from 'express';
import { analyze, detection, installerTypes } from '../controllers/packagingController.js';
import { analyze, applyRecipe, detection, installerTypes, recipes } from '../controllers/packagingController.js';
import { requireAuth } from '../middleware/auth.js';
export const packagingRoutes = Router();
// Phase 1 installer intelligence: detect technology, recommend silent commands,
// and generate detection rules.
packagingRoutes.get('/installer-types', requireAuth, installerTypes);
packagingRoutes.post('/analyze', requireAuth, analyze);
packagingRoutes.post('/detection', requireAuth, detection);
packagingRoutes.get('/recipes', requireAuth, recipes);
packagingRoutes.post('/recipes/:id/apply', requireAuth, applyRecipe);

View File

@@ -5,6 +5,7 @@ import {
destroy,
index,
intuneCreate,
intuneDatasheet,
intuneDestroy,
intuneGraphAssign,
intuneGraphAudit,
@@ -25,6 +26,7 @@ import {
intuneUpdate,
migrationApply,
migrationPlan,
packageProfile,
render,
rules,
show,
@@ -60,9 +62,11 @@ psadtRoutes.post('/intune/deployments/:id/build', requireAuth, intuneBuild);
psadtRoutes.post('/intune/deployments/:id/new-version', requireAuth, intuneNewVersion);
psadtRoutes.post('/intune/deployments/:id/promote', requireAuth, intunePromote);
psadtRoutes.get('/intune/deployments/:id/graph/audit', requireAuth, intuneGraphAudit);
psadtRoutes.get('/intune/deployments/:id/datasheet', requireAuth, intuneDatasheet);
psadtRoutes.get('/profiles', requireAuth, index);
psadtRoutes.post('/profiles', requireAuth, create);
psadtRoutes.get('/profiles/:id', requireAuth, show);
psadtRoutes.put('/profiles/:id', requireAuth, update);
psadtRoutes.delete('/profiles/:id', requireAuth, destroy);
psadtRoutes.get('/profiles/:id/render', requireAuth, render);
psadtRoutes.post('/profiles/:id/package', requireAuth, packageProfile);

View File

@@ -0,0 +1,123 @@
// Minimal reader for the OLE2 / Compound File Binary (CFB) container format —
// the on-disk shape of an MSI database (and legacy Office docs). Dependency-free
// and read-only: it walks the FAT / mini-FAT and directory to expose each
// stream as a Buffer keyed by its (decoded) directory name. Just enough of
// [MS-CFB] to pull MSI tables; it does not write or modify compound files.
const SIG = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
const ENDOFCHAIN = 0xfffffffe;
const FREESECT = 0xffffffff;
const MAX_CHAIN = 1 << 24; // chain-length guard against malformed/looping files
export function isCompoundFile(buffer) {
return Buffer.isBuffer(buffer) && buffer.length >= 512 && buffer.subarray(0, 8).equals(SIG);
}
function decodeName(buffer, nameLen) {
// nameLen includes the trailing UTF-16 null terminator.
if (!nameLen || nameLen < 2) return '';
return buffer.toString('utf16le', 0, nameLen - 2);
}
function followChain(start, fat, limit) {
const chain = [];
let sector = start;
let guard = 0;
while (sector !== ENDOFCHAIN && sector !== FREESECT && sector < limit) {
chain.push(sector);
if (++guard > MAX_CHAIN) throw new Error('CFB sector chain too long (malformed file)');
sector = fat[sector];
if (sector == null) break;
}
return chain;
}
export function readCompoundFile(buffer) {
if (!isCompoundFile(buffer)) throw new Error('Not a compound file (bad OLE2 signature)');
const sectorShift = buffer.readUInt16LE(30);
const miniSectorShift = buffer.readUInt16LE(32);
const sectorSize = 1 << sectorShift;
const miniSectorSize = 1 << miniSectorShift;
const numFatSectors = buffer.readUInt32LE(44);
const firstDirSector = buffer.readUInt32LE(48);
const miniCutoff = buffer.readUInt32LE(56);
const firstMiniFatSector = buffer.readUInt32LE(60);
const firstDifatSector = buffer.readUInt32LE(68);
const numDifatSectors = buffer.readUInt32LE(72);
const sectorOffset = (sector) => (sector + 1) * sectorSize;
const totalSectors = Math.floor(buffer.length / sectorSize);
// 1) Assemble the DIFAT (locations of every FAT sector): 109 from the header,
// then the DIFAT sector chain.
const fatSectorLocations = [];
for (let i = 0; i < 109 && fatSectorLocations.length < numFatSectors; i += 1) {
const loc = buffer.readUInt32LE(76 + i * 4);
if (loc !== FREESECT) fatSectorLocations.push(loc);
}
let difatSector = firstDifatSector;
const entriesPerDifat = sectorSize / 4 - 1;
for (let s = 0; s < numDifatSectors && difatSector !== ENDOFCHAIN && difatSector !== FREESECT; s += 1) {
const base = sectorOffset(difatSector);
for (let i = 0; i < entriesPerDifat; i += 1) {
const loc = buffer.readUInt32LE(base + i * 4);
if (loc !== FREESECT && fatSectorLocations.length < numFatSectors) fatSectorLocations.push(loc);
}
difatSector = buffer.readUInt32LE(base + entriesPerDifat * 4);
}
// 2) Build the FAT: a flat next-sector table.
const entriesPerSector = sectorSize / 4;
const fat = new Uint32Array(fatSectorLocations.length * entriesPerSector);
let fi = 0;
for (const loc of fatSectorLocations) {
const base = sectorOffset(loc);
for (let i = 0; i < entriesPerSector; i += 1) fat[fi++] = buffer.readUInt32LE(base + i * 4);
}
const readChain = (start) => {
const chain = followChain(start, fat, totalSectors);
const out = Buffer.allocUnsafe(chain.length * sectorSize);
chain.forEach((sector, idx) => buffer.copy(out, idx * sectorSize, sectorOffset(sector), sectorOffset(sector) + sectorSize));
return out;
};
// 3) Directory entries.
const dirBytes = readChain(firstDirSector);
const entries = [];
for (let off = 0; off + 128 <= dirBytes.length; off += 128) {
const type = dirBytes.readUInt8(off + 66);
if (type === 0) continue; // unused slot
entries.push({
name: decodeName(dirBytes.subarray(off, off + 64), dirBytes.readUInt16LE(off + 64)),
type,
startSector: dirBytes.readUInt32LE(off + 116),
size: Number(dirBytes.readBigUInt64LE(off + 120))
});
}
// 4) Mini stream lives in the root entry; small streams are read from it.
const root = entries.find((e) => e.type === 5);
const miniStream = root && root.size > 0 ? readChain(root.startSector).subarray(0, root.size) : Buffer.alloc(0);
const miniFatBytes = firstMiniFatSector === ENDOFCHAIN ? Buffer.alloc(0) : readChain(firstMiniFatSector);
const miniFat = new Uint32Array(miniFatBytes.length / 4);
for (let i = 0; i < miniFat.length; i += 1) miniFat[i] = miniFatBytes.readUInt32LE(i * 4);
const readMiniChain = (start, size) => {
const chain = followChain(start, miniFat, miniStream.length / miniSectorSize + 1);
const out = Buffer.allocUnsafe(chain.length * miniSectorSize);
chain.forEach((sector, idx) => miniStream.copy(out, idx * miniSectorSize, sector * miniSectorSize, sector * miniSectorSize + miniSectorSize));
return out.subarray(0, size);
};
const streams = new Map();
for (const entry of entries) {
if (entry.type !== 2) continue; // streams only
const data = entry.size < miniCutoff
? readMiniChain(entry.startSector, entry.size)
: readChain(entry.startSector).subarray(0, entry.size);
streams.set(entry.name, data);
}
return { streams };
}

View File

@@ -0,0 +1,165 @@
// Read identity metadata from a Windows Installer (.msi) database. An MSI is a
// compound file (see cfbReader) whose tables are stored as streams. We pull the
// `Property` table (ProductCode/Version/Name, UpgradeCode, Manufacturer) and the
// SummaryInformation property set (subject/author/template) — enough to auto-fill
// a deployment and a product-code detection rule with zero typing. Dependency-
// free and read-only; the table-decoding helpers are pure and unit-tested.
import { isCompoundFile, readCompoundFile } from './cfbReader.js';
const SUMMARY_STREAM = 'SummaryInformation';
// MSI mangles table/stream names into a Unicode private range. Encode a plain
// table name ("Property") the same way the directory stores it, so we can look
// it up in the decoded streams map. [A-Za-z0-9._] map to 6-bit codes packed in
// pairs (0x3800 base) with a trailing odd char at 0x4800.
function encChar(code) {
if (code >= 0x30 && code <= 0x39) return code - 0x30; // 0-9
if (code >= 0x41 && code <= 0x5a) return code - 0x41 + 10; // A-Z
if (code >= 0x61 && code <= 0x7a) return code - 0x61 + 36; // a-z
if (code === 0x2e) return 62; // .
if (code === 0x5f) return 63; // _
return -1;
}
export function encodeStreamName(name) {
let out = '';
let i = 0;
while (i < name.length) {
const c1 = encChar(name.charCodeAt(i));
if (c1 < 0) { out += name[i]; i += 1; continue; }
const c2 = i + 1 < name.length ? encChar(name.charCodeAt(i + 1)) : -1;
if (c2 >= 0) { out += String.fromCharCode(0x3800 + c1 + c2 * 0x40); i += 2; }
else { out += String.fromCharCode(0x4800 + c1); i += 1; }
}
return out;
}
function decoderFor(codepage) {
if (codepage === 65001) return (b) => b.toString('utf8');
if (codepage === 1200) return (b) => b.toString('utf16le');
return (b) => b.toString('latin1'); // 1252/0/ASCII — fine for property keys + GUIDs
}
// _StringPool is an array of (size, refcount) uint16 pairs; slot 0 carries the
// codepage. _StringData is the concatenated bytes. Strings are 1-indexed; a zero
// size with a nonzero refcount marks a >64KB string whose length spans two slots.
export function decodeStringPool(poolBuf, dataBuf) {
const codepage = poolBuf.length >= 2 ? poolBuf.readUInt16LE(0) : 0;
const decode = decoderFor(codepage);
const strings = ['']; // index 0 is always the empty string
let dataOff = 0;
let i = 1;
while (i * 4 + 4 <= poolBuf.length) {
let len = poolBuf.readUInt16LE(i * 4);
const refcount = poolBuf.readUInt16LE(i * 4 + 2);
i += 1;
if (len === 0 && refcount === 0) { strings.push(''); continue; }
if (len === 0 && i * 4 + 4 <= poolBuf.length) {
len = (refcount << 16) | poolBuf.readUInt16LE(i * 4);
i += 1;
}
strings.push(decode(dataBuf.subarray(dataOff, dataOff + len)));
dataOff += len;
}
return { codepage, strings };
}
// The Property table has two string columns (Property, Value), stored column-
// major. Cells are string-pool indices: 2 bytes, or 3 when the pool is huge.
export function parsePropertyTable(tableBuf, strings) {
const idxWidth = strings.length > 0xffff ? 3 : 2;
const rowBytes = idxWidth * 2;
const rows = Math.floor(tableBuf.length / rowBytes);
const readIdx = (off) => (idxWidth === 2 ? tableBuf.readUInt16LE(off) : tableBuf.readUIntLE(off, 3));
const props = {};
for (let r = 0; r < rows; r += 1) {
const key = strings[readIdx(r * idxWidth)];
if (!key) continue;
props[key] = strings[readIdx(rows * idxWidth + r * idxWidth)] || '';
}
return props;
}
function readPropValue(buf, off, type, decode) {
switch (type) {
case 2: return buf.readInt16LE(off); // VT_I2
case 3: return buf.readInt32LE(off); // VT_I4
case 30: { // VT_LPSTR
const len = buf.readUInt32LE(off);
return decode(buf.subarray(off + 4, off + 4 + len)).replace(/\0+$/, '');
}
case 31: { // VT_LPWSTR
const len = buf.readUInt32LE(off);
return buf.toString('utf16le', off + 4, off + 4 + len * 2).replace(/\0+$/, '');
}
default: return null;
}
}
// SummaryInformation is a standard OLE property set ([MS-OLEPS]). We read the
// first section and pull the PIDs MSI populates (subject, author, template…).
export function parseSummaryInformation(buf) {
if (!buf || buf.length < 48) return {};
const sectionOffset = buf.readUInt32LE(44); // FMTID(16) at 28, its offset at 44
if (sectionOffset + 8 > buf.length) return {};
const count = buf.readUInt32LE(sectionOffset + 4);
const idOffsets = [];
for (let i = 0; i < count && sectionOffset + 8 + i * 8 + 8 <= buf.length; i += 1) {
idOffsets.push([
buf.readUInt32LE(sectionOffset + 8 + i * 8),
sectionOffset + buf.readUInt32LE(sectionOffset + 8 + i * 8 + 4)
]);
}
let codepage = 0;
for (const [pid, off] of idOffsets) {
if (pid === 1 && off + 6 <= buf.length && buf.readUInt32LE(off) === 2) {
const cp = buf.readInt16LE(off + 4);
codepage = cp < 0 ? cp + 65536 : cp;
}
}
const decode = decoderFor(codepage);
const PID = { 2: 'title', 3: 'subject', 4: 'author', 6: 'comments', 7: 'template', 9: 'revNumber', 18: 'creatingApp' };
const out = {};
for (const [pid, off] of idOffsets) {
if (!PID[pid] || off + 4 > buf.length) continue;
const value = readPropValue(buf, off + 4, buf.readUInt32LE(off), decode);
if (value != null) out[PID[pid]] = value;
}
return out;
}
// Pure: turn a decoded streams map (from cfbReader) into normalized metadata.
export function extractMsiMetadata(streams) {
const props = {};
const pool = streams.get('_StringPool');
const data = streams.get('_StringData');
if (pool && data) {
const { strings } = decodeStringPool(pool, data);
const table = streams.get(encodeStreamName('Property'));
if (table) Object.assign(props, parsePropertyTable(table, strings));
}
const summary = parseSummaryInformation(streams.get(SUMMARY_STREAM));
const [platform = '', language = ''] = String(summary.template || '').split(';');
return {
source: 'msi',
productName: props.ProductName || summary.subject || summary.title || '',
productVersion: props.ProductVersion || '',
productCode: props.ProductCode || '',
upgradeCode: props.UpgradeCode || '',
manufacturer: props.Manufacturer || summary.author || '',
language: props.ProductLanguage || language.trim(),
platform: platform.trim(),
packageCode: summary.revNumber || '',
properties: props
};
}
export function isMsi(buffer) {
return isCompoundFile(buffer);
}
export function readMsiMetadata(buffer) {
if (!isCompoundFile(buffer)) throw new Error('Not an MSI (missing OLE2 compound-file signature)');
return extractMsiMetadata(readCompoundFile(buffer).streams);
}

View File

@@ -0,0 +1,149 @@
import zlib from 'node:zlib';
import { basenameAny, normalizePackagePath } from '../utils/pathUtils.js';
// Assembles a PSADT v4 package tree from a rendered profile script plus its linked
// Asset Library files, and zips it without an archiver dependency. The layout
// planning and the ZIP writer are pure and unit-tested; disk reads stay in the
// controller.
export const ENTRY_SCRIPT_NAME = 'Invoke-AppDeployToolkit.ps1';
// Link role -> default PSADT v4 subfolder when the link carries no explicit
// package path. Installers/payload go under Files, supporting content under
// SupportFiles, icons under Assets.
const ROLE_FOLDER = {
installer: 'Files',
'package-file': 'Files',
'support-file': 'SupportFiles',
'detection-script': 'SupportFiles',
icon: 'Assets',
reference: 'SupportFiles'
};
// Pure: decide where the rendered script and each linked asset land in the
// package tree. An explicit link packagePath wins; otherwise the role picks a
// standard folder and the asset's own file name. Collisions are dropped with a
// warning so a package never silently overwrites a file.
export function planPackageLayout(links = []) {
const warnings = [];
const used = new Map();
used.set(ENTRY_SCRIPT_NAME.toLowerCase(), ENTRY_SCRIPT_NAME);
const assets = [];
for (const link of links) {
const folder = ROLE_FOLDER[link.linkRole] || 'SupportFiles';
const name = basenameAny(link.packagePath || link.originalName || link.assetId);
let target = normalizePackagePath(link.packagePath, `${folder}/${name}`) || `${folder}/${name}`;
if (!target.includes('/')) target = `${folder}/${target}`;
const key = target.toLowerCase();
if (used.has(key)) {
warnings.push(`Skipped "${link.originalName || link.assetId}": another file already maps to ${target}.`);
continue;
}
used.set(key, link.originalName || link.assetId);
assets.push({ assetId: link.assetId, packagePath: target, role: link.linkRole, name: link.originalName });
}
const directories = ['Files', 'SupportFiles'];
if (assets.some((asset) => asset.packagePath.startsWith('Assets/'))) directories.push('Assets');
return { scriptPath: ENTRY_SCRIPT_NAME, assets, directories, warnings };
}
// Pure: pick the installer the .intunewin build should treat as its entry setup
// file (relative to the package root). An explicit choice wins; otherwise prefer
// an installer-role asset, then the first file under Files/.
export function resolveSetupFile(layout, explicit = '') {
const explicitClean = normalizePackagePath(explicit, '');
if (explicitClean) return explicitClean;
const installer = layout.assets.find((asset) => asset.role === 'installer');
if (installer) return installer.packagePath;
const firstFile = layout.assets.find((asset) => asset.packagePath.startsWith('Files/'));
return firstFile ? firstFile.packagePath : '';
}
let CRC_TABLE;
function crc32(buf) {
if (!CRC_TABLE) {
CRC_TABLE = new Int32Array(256);
for (let n = 0; n < 256; n += 1) {
let c = n;
for (let k = 0; k < 8; k += 1) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
CRC_TABLE[n] = c;
}
}
let crc = -1;
for (let i = 0; i < buf.length; i += 1) crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buf[i]) & 0xff];
return (crc ^ -1) >>> 0;
}
// Pure: build a standard PKZIP buffer from entries. Files are deflate-compressed
// (method 8), directories and empty files stored (method 0). A fixed 1980 DOS
// timestamp keeps output reproducible. Round-trips through zlib.inflateRawSync.
export function buildZip(entries = []) {
const local = [];
const central = [];
let offset = 0;
for (const entry of entries) {
const isDir = Boolean(entry.isDirectory);
let name = String(entry.path).replace(/\\/g, '/');
if (isDir && !name.endsWith('/')) name += '/';
const nameBytes = Buffer.from(name, 'utf8');
const raw = isDir ? Buffer.alloc(0) : (Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data || '', 'utf8'));
const deflated = raw.length ? zlib.deflateRawSync(raw) : Buffer.alloc(0);
const useDeflate = raw.length > 0 && deflated.length < raw.length;
const method = useDeflate ? 8 : 0;
const stored = useDeflate ? deflated : raw;
const crc = crc32(raw);
const localHeader = Buffer.alloc(30);
localHeader.writeUInt32LE(0x04034b50, 0);
localHeader.writeUInt16LE(20, 4);
localHeader.writeUInt16LE(0, 6);
localHeader.writeUInt16LE(method, 8);
localHeader.writeUInt16LE(0, 10);
localHeader.writeUInt16LE(0x21, 12);
localHeader.writeUInt32LE(crc, 14);
localHeader.writeUInt32LE(stored.length, 18);
localHeader.writeUInt32LE(raw.length, 22);
localHeader.writeUInt16LE(nameBytes.length, 26);
localHeader.writeUInt16LE(0, 28);
local.push(localHeader, nameBytes, stored);
const centralHeader = Buffer.alloc(46);
centralHeader.writeUInt32LE(0x02014b50, 0);
centralHeader.writeUInt16LE(20, 4);
centralHeader.writeUInt16LE(20, 6);
centralHeader.writeUInt16LE(0, 8);
centralHeader.writeUInt16LE(method, 10);
centralHeader.writeUInt16LE(0, 12);
centralHeader.writeUInt16LE(0x21, 14);
centralHeader.writeUInt32LE(crc, 16);
centralHeader.writeUInt32LE(stored.length, 20);
centralHeader.writeUInt32LE(raw.length, 24);
centralHeader.writeUInt16LE(nameBytes.length, 28);
centralHeader.writeUInt16LE(0, 30);
centralHeader.writeUInt16LE(0, 32);
centralHeader.writeUInt16LE(0, 34);
centralHeader.writeUInt16LE(0, 36);
centralHeader.writeUInt32LE(isDir ? 0x10 : 0, 38);
centralHeader.writeUInt32LE(offset, 42);
central.push(centralHeader, nameBytes);
offset += localHeader.length + nameBytes.length + stored.length;
}
const centralBuffer = Buffer.concat(central);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(0, 4);
end.writeUInt16LE(0, 6);
end.writeUInt16LE(entries.length, 8);
end.writeUInt16LE(entries.length, 10);
end.writeUInt32LE(centralBuffer.length, 12);
end.writeUInt32LE(offset, 16);
end.writeUInt16LE(0, 20);
return Buffer.concat([...local, centralBuffer, end]);
}

View File

@@ -0,0 +1,244 @@
// Package documentation — turn an Intune deployment (+ optional linked PSADT
// profile and catalog application) into an auto-generated datasheet. Pure: it
// renders a section model to Markdown or self-contained HTML with no IO. The
// controller loads the records and picks the format.
const RETURN_CODE_LABELS = {
success: 'Success',
softReboot: 'Soft reboot',
hardReboot: 'Hard reboot',
retry: 'Retry',
failed: 'Failed'
};
const INTENT_LABELS = {
available: 'Available',
required: 'Required',
uninstall: 'Uninstall',
exclude: 'Excluded'
};
function dash(value) {
const str = value == null ? '' : String(value).trim();
return str === '' ? '—' : str;
}
// Build a format-neutral section model from the loaded records. Each section is
// one of: { rows } definition list, { table } grid, { code } block, or { text }.
export function buildDatasheetModel({ deployment, profile = null, application = null } = {}) {
if (!deployment) throw new Error('deployment is required');
const title = deployment.name || profile?.name || 'PSADT package';
const vendor = profile?.appVendor || application?.vendor || '';
const appName = profile?.appName || deployment.name || '';
const version = profile?.appVersion || application?.version || '';
const arch = profile?.appArch || deployment.requirements?.architecture || '';
const subtitle = [vendor, appName, version].map((part) => String(part || '').trim()).filter(Boolean).join(' ');
const sections = [];
sections.push({
heading: 'Overview',
rows: [
['Package', title],
['Vendor', vendor],
['Application', appName],
['Version', version],
['Architecture', arch],
['App type', deployment.appType],
['Command style', deployment.commandStyle],
['Install behavior', deployment.installBehavior],
['Restart behavior', deployment.restartBehavior],
['Status', deployment.status]
]
});
sections.push({
heading: 'Install & uninstall',
rows: [
['Install command', { code: deployment.installCommand }],
['Uninstall command', { code: deployment.uninstallCommand }]
]
});
sections.push({
heading: 'Detection',
rows: [['Detection type', deployment.detectionType || 'manual']],
code: deployment.detectionRule ? deployment.detectionRule : null
});
const returnCodes = Array.isArray(deployment.returnCodes) ? deployment.returnCodes : [];
sections.push({
heading: 'Return codes',
table: {
columns: ['Code', 'Type', 'Meaning'],
rows: returnCodes.length
? returnCodes.map((rc) => [String(rc.code), RETURN_CODE_LABELS[rc.type] || rc.type || '', rc.meaning || ''])
: []
}
});
const assignments = Array.isArray(deployment.assignments) ? deployment.assignments : [];
sections.push({
heading: 'Assignments',
table: {
columns: ['Ring', 'Intent', 'Group', 'Notes'],
rows: assignments.length
? assignments.map((a) => [a.ring || '', INTENT_LABELS[a.intent] || a.intent || '', a.groupName || '', a.notes || ''])
: []
}
});
const req = deployment.requirements || {};
sections.push({
heading: 'Requirements',
rows: [
['Architecture', req.architecture],
['Minimum OS', req.minOs],
['Disk space (MB)', req.diskSpaceMb ? String(req.diskSpaceMb) : ''],
['Run as 32-bit', req.runAs32Bit ? 'Yes' : 'No']
]
});
sections.push({
heading: 'Source & version',
rows: [
['PSADT profile', profile?.name || deployment.profileName || ''],
['Source folder', deployment.sourceFolder],
['Package file', deployment.intunewinFile],
['Catalog application', application?.name || deployment.applicationName || ''],
['Version source', application ? `${application.versionSource || ''} ${application.versionSourceRef || ''}`.trim() : '']
]
});
if (deployment.notes) {
sections.push({ heading: 'Notes', text: deployment.notes });
}
return { title, subtitle, generatedAt: new Date().toISOString(), sections };
}
function rowValueToMd(value) {
if (value && typeof value === 'object' && 'code' in value) return `\`${dash(value.code).replace(/`/g, '`')}\``;
return dash(value);
}
function renderMarkdown(model) {
const lines = [`# ${model.title}`, ''];
if (model.subtitle) lines.push(`*${model.subtitle}*`, '');
lines.push(`Generated ${model.generatedAt}`, '');
for (const section of model.sections) {
lines.push(`## ${section.heading}`, '');
if (section.rows) {
lines.push('| Field | Value |', '| --- | --- |');
for (const [label, value] of section.rows) {
lines.push(`| ${label} | ${rowValueToMd(value).replace(/\n/g, ' ')} |`);
}
lines.push('');
}
if (section.table) {
const { columns, rows } = section.table;
lines.push(`| ${columns.join(' | ')} |`, `| ${columns.map(() => '---').join(' | ')} |`);
if (rows.length) {
for (const row of rows) lines.push(`| ${row.map((cell) => dash(cell).replace(/\n/g, ' ').replace(/\|/g, '\\|')).join(' | ')} |`);
} else {
lines.push(`| ${columns.map((_, i) => (i === 0 ? '_None_' : '')).join(' | ')} |`);
}
lines.push('');
}
if (section.code) {
lines.push('```', section.code, '```', '');
}
if (section.text) {
lines.push(section.text, '');
}
}
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
}
function esc(value) {
return dash(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function rowValueToHtml(value) {
if (value && typeof value === 'object' && 'code' in value) return `<code>${esc(value.code)}</code>`;
return esc(value);
}
function renderHtml(model) {
const parts = [];
for (const section of model.sections) {
parts.push(`<section><h2>${esc(section.heading)}</h2>`);
if (section.rows) {
parts.push('<table class="kv"><tbody>');
for (const [label, value] of section.rows) {
parts.push(`<tr><th>${esc(label)}</th><td>${rowValueToHtml(value)}</td></tr>`);
}
parts.push('</tbody></table>');
}
if (section.table) {
const { columns, rows } = section.table;
parts.push('<table class="grid"><thead><tr>');
parts.push(columns.map((col) => `<th>${esc(col)}</th>`).join(''));
parts.push('</tr></thead><tbody>');
if (rows.length) {
for (const row of rows) {
parts.push('<tr>' + row.map((cell) => `<td>${esc(cell)}</td>`).join('') + '</tr>');
}
} else {
parts.push(`<tr><td class="muted" colspan="${columns.length}">None configured</td></tr>`);
}
parts.push('</tbody></table>');
}
if (section.code) {
parts.push(`<pre><code>${esc(section.code)}</code></pre>`);
}
if (section.text) {
parts.push(`<p>${esc(section.text)}</p>`);
}
parts.push('</section>');
}
return [
'<!doctype html>',
'<html lang="en"><head><meta charset="utf-8">',
`<title>${esc(model.title)} — package datasheet</title>`,
'<style>',
':root{color-scheme:light dark}',
'body{font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:60rem;margin:2rem auto;padding:0 1rem;line-height:1.5}',
'h1{margin-bottom:.25rem}.subtitle{color:#6b7280;margin-top:0}.meta{color:#6b7280;font-size:.85rem}',
'section{margin-top:1.75rem}h2{border-bottom:1px solid #d1d5db;padding-bottom:.25rem}',
'table{border-collapse:collapse;width:100%;margin-top:.5rem}',
'th,td{text-align:left;padding:.4rem .6rem;border:1px solid #d1d5db;vertical-align:top}',
'table.kv th{width:14rem;white-space:nowrap}',
'code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.9em}',
'pre{background:#f3f4f6;padding:.75rem;border-radius:.4rem;overflow:auto}pre code{font-size:.85em}',
'.muted{color:#6b7280}',
'</style></head><body>',
`<h1>${esc(model.title)}</h1>`,
model.subtitle ? `<p class="subtitle">${esc(model.subtitle)}</p>` : '',
`<p class="meta">Generated ${esc(model.generatedAt)}</p>`,
parts.join('\n'),
'</body></html>',
''
].filter((line) => line !== '').join('\n');
}
// Render a datasheet for a deployment. format: 'md' (default) | 'html'.
export function renderDatasheet(records, { format = 'md' } = {}) {
const model = buildDatasheetModel(records);
const normalized = format === 'html' ? 'html' : 'md';
const content = normalized === 'html' ? renderHtml(model) : renderMarkdown(model);
return {
format: normalized,
contentType: normalized === 'html' ? 'text/html; charset=utf-8' : 'text/markdown; charset=utf-8',
extension: normalized === 'html' ? 'html' : 'md',
title: model.title,
content
};
}

View File

@@ -1,34 +1,10 @@
import { createRequire } from 'node:module';
import { randomBytes } from 'node:crypto';
import { path } from '../utils/pathUtils.js';
import express from 'express';
import { db, visibleClause } from '../db.js';
import { config } from '../config.js';
import { decryptSecret } from './cryptoStore.js';
import { logger } from './logger.js';
import { getBooleanSetting, getStringSetting } from '../models/settingsModel.js';
import { normalizeOsFamily } from '../utils/osFamily.js';
const require = createRequire(import.meta.url);
const rdp = require('node-rdpjs');
const socketIo = require('socket.io');
const mstscPackagePath = require.resolve('mstsc.js/package.json');
const mstscClientDir = path.join(path.dirname(mstscPackagePath), 'client');
const SESSION_TTL_MS = 5 * 60 * 1000;
const sessions = new Map();
let socketServer = null;
function token() {
return randomBytes(24).toString('base64url');
}
function escapeHtml(value = '') {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function splitDomainUsername(username = '') {
const value = String(username || '');
const match = value.match(/^([^\\]+)\\(.+)$/);
@@ -36,11 +12,47 @@ function splitDomainUsername(username = '') {
return { domain: match[1], username: match[2] };
}
function cleanupExpiredSessions() {
const cutoff = Date.now() - SESSION_TTL_MS;
for (const [sessionToken, session] of sessions.entries()) {
if (session.createdAt < cutoff) sessions.delete(sessionToken);
function numberSetting(key, fallback, min, max) {
const value = Number(getStringSetting(key, String(fallback)));
if (!Number.isFinite(value)) return fallback;
return Math.max(min, Math.min(value, max));
}
function makeHttpError(message, statusCode = 400, details = undefined) {
const error = new Error(message);
error.statusCode = statusCode;
if (details) error.details = details;
return error;
}
export function normalizeMyrtilleGatewayUrl(value) {
const trimmed = String(value || '').trim();
if (!trimmed) return '';
const normalized = trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
const url = new URL(normalized);
if (!['http:', 'https:'].includes(url.protocol)) {
throw makeHttpError('Myrtille gateway URL must use http or https.', 400);
}
return url.toString();
}
function normalizeHashEndpoint(value) {
const trimmed = String(value || '').trim();
return trimmed || 'GetHash.aspx';
}
function myrtilleSettings() {
const gatewayUrl = normalizeMyrtilleGatewayUrl(getStringSetting('myrtille_gateway_url', config.myrtille.gatewayUrl));
return {
enabled: getBooleanSetting('myrtille_enabled', config.myrtille.enabled),
gatewayUrl,
usePasswordHash: getBooleanSetting('myrtille_use_password_hash', config.myrtille.usePasswordHash),
allowPlainPassword: getBooleanSetting('myrtille_allow_plain_password', config.myrtille.allowPlainPassword),
hashEndpoint: normalizeHashEndpoint(getStringSetting('myrtille_hash_endpoint', config.myrtille.hashEndpoint)),
width: numberSetting('myrtille_default_width', config.myrtille.defaultWidth, 640, 7680),
height: numberSetting('myrtille_default_height', config.myrtille.defaultHeight, 480, 4320),
requestTimeoutMs: numberSetting('myrtille_request_timeout_ms', config.myrtille.requestTimeoutMs, 1000, 120000)
};
}
function loadRdpTarget(hostId, userId) {
@@ -55,246 +67,158 @@ function loadRdpTarget(hostId, userId) {
`).get(hostId, userId, userId, userId, userId);
}
export function createRdpLaunchSession(hostId, userId) {
cleanupExpiredSessions();
async function fetchMyrtillePasswordHash(settings, password) {
const url = new URL(settings.hashEndpoint, settings.gatewayUrl);
url.searchParams.set('password', password);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), settings.requestTimeoutMs);
try {
const response = await fetch(url, {
method: 'GET',
headers: { Accept: 'text/plain, text/html, */*' },
signal: controller.signal
});
const body = (await response.text()).trim();
if (!response.ok) {
throw makeHttpError(
`Myrtille password hash endpoint returned HTTP ${response.status}.`,
502,
{ status: response.status, body: body.slice(0, 500) }
);
}
const hash = extractHashFromMyrtilleResponse(body);
if (!hash) {
throw makeHttpError('Myrtille password hash endpoint did not return a usable hash.', 502, { body: body.slice(0, 500) });
}
return hash;
} catch (error) {
if (error?.name === 'AbortError') {
throw makeHttpError(`Myrtille password hash request timed out after ${settings.requestTimeoutMs}ms.`, 502);
}
if (error?.statusCode) throw error;
throw makeHttpError(`Unable to request a Myrtille password hash: ${error.message}`, 502);
} finally {
clearTimeout(timeout);
}
}
export function extractHashFromMyrtilleResponse(body = '') {
const text = String(body || '').trim();
if (!text) return '';
const inputMatch = text.match(/value=["']([^"']+)["']/i);
if (inputMatch?.[1]) return inputMatch[1].trim();
const markerMatch = text.match(/passwordHash["'\s:=>-]+([A-Za-z0-9+/=_:-]+)/i);
if (markerMatch?.[1]) return markerMatch[1].trim();
return text.replace(/<[^>]+>/g, ' ').trim().split(/\s+/).find((part) => part.length >= 8) || '';
}
export function buildMyrtilleLaunchUrl({
gatewayUrl,
target,
domain = '',
username,
passwordHash = '',
password = '',
width = 1280,
height = 800
}) {
const url = new URL(gatewayUrl);
url.searchParams.set('__EVENTTARGET', '');
url.searchParams.set('__EVENTARGUMENT', '');
url.searchParams.set('server', target);
if (domain) url.searchParams.set('domain', domain);
url.searchParams.set('user', username);
if (passwordHash) url.searchParams.set('passwordHash', passwordHash);
else if (password) url.searchParams.set('password', password);
url.searchParams.set('width', String(width));
url.searchParams.set('height', String(height));
url.searchParams.set('connect', 'Connect!');
return url.toString();
}
export async function createRdpLaunchSession(hostId, userId) {
const target = loadRdpTarget(hostId, userId);
if (!target) {
const error = new Error('Windows host with an assigned visible credential is required for RDP launch.');
error.statusCode = 404;
throw error;
throw makeHttpError('Windows host with an assigned visible credential is required for RDP launch.', 404);
}
if (normalizeOsFamily(target.os_family, 'other') !== 'windows') {
const error = new Error('RDP launch is only available for Windows hosts.');
error.statusCode = 400;
throw error;
throw makeHttpError('RDP launch is only available for Windows hosts.', 400);
}
if (target.credential_kind !== 'username_password') {
const error = new Error('RDP launch requires a username/password credential assigned to the host.');
error.statusCode = 400;
throw error;
throw makeHttpError('RDP launch requires a username/password credential assigned to the host.', 400);
}
const settings = myrtilleSettings();
if (!settings.enabled || !settings.gatewayUrl) {
throw makeHttpError('Myrtille RDP gateway is not configured. Enable myrtille_enabled and set myrtille_gateway_url in Configuration or environment variables.', 400);
}
const password = decryptSecret(target);
if (!password) {
const error = new Error('Assigned host credential does not have a decryptable password.');
error.statusCode = 400;
throw error;
throw makeHttpError('Assigned host credential does not have a decryptable password.', 400);
}
const sessionToken = token();
const address = target.fqdn || target.address;
const parsedUsername = splitDomainUsername(target.credential_username);
const session = {
token: sessionToken,
userId,
hostId: target.id,
hostName: target.name,
address: target.fqdn || target.address,
port: 3389,
let passwordHash = '';
let plainPassword = '';
if (settings.usePasswordHash) {
try {
passwordHash = await fetchMyrtillePasswordHash(settings, password);
} catch (error) {
if (!settings.allowPlainPassword) {
throw makeHttpError(
`${error.message} Myrtille password-hash mode is enabled and plaintext URL fallback is disabled.`,
error.statusCode || 502,
error.details
);
}
logger.warn('falling back to plaintext Myrtille password launch because hash generation failed and fallback is enabled', {
hostId: target.id,
hostName: target.name,
userId,
error: error.message
});
plainPassword = password;
}
} else {
if (!settings.allowPlainPassword) {
throw makeHttpError('Myrtille plaintext password launch is disabled. Enable password-hash mode or explicitly allow plaintext fallback.', 400);
}
plainPassword = password;
}
const url = buildMyrtilleLaunchUrl({
gatewayUrl: settings.gatewayUrl,
target: address,
domain: parsedUsername.domain,
username: parsedUsername.username,
password,
credentialName: target.credential_name,
createdAt: Date.now()
};
sessions.set(sessionToken, session);
logger.info('rdp session created', {
passwordHash,
password: plainPassword,
width: settings.width,
height: settings.height
});
logger.info('myrtille rdp launch created', {
hostId: target.id,
hostName: target.name,
userId,
credentialId: target.credential_id
credentialId: target.credential_id,
gatewayUrl: settings.gatewayUrl,
credentialMode: passwordHash ? 'passwordHash' : 'password'
});
return {
token: sessionToken,
url: `/rdp/${sessionToken}`,
expiresInSeconds: Math.floor(SESSION_TTL_MS / 1000),
provider: 'myrtille',
url,
gatewayUrl: settings.gatewayUrl,
expiresInSeconds: 0,
credentialMode: passwordHash ? 'passwordHash' : 'password',
host: {
id: target.id,
name: target.name,
address: session.address
address
}
};
}
function renderSessionPage(session) {
const safeToken = escapeHtml(session.token);
const safeHost = escapeHtml(session.hostName);
const safeAddress = escapeHtml(session.address);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>RDP - ${safeHost}</title>
<link rel="icon" href="/rdp/assets/img/favicon.ico" />
<script src="/rdp/socket.io/socket.io.js"></script>
<script src="/rdp/assets/js/mstsc.js"></script>
<script src="/rdp/assets/js/keyboard.js"></script>
<script src="/rdp/assets/js/rle.js"></script>
<script src="/rdp/assets/js/client.js"></script>
<script src="/rdp/assets/js/canvas.js"></script>
<style>
:root { color-scheme: dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
overflow: hidden;
color: #eff7ff;
background:
radial-gradient(circle at 18% 12%, rgba(109, 102, 255, .25), transparent 34%),
radial-gradient(circle at 78% 18%, rgba(88, 221, 255, .22), transparent 32%),
linear-gradient(135deg, #090d1e, #101a32 48%, #050814);
}
.launch {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
}
.panel {
width: min(560px, 100%);
border: 1px solid rgba(156, 199, 232, .24);
border-radius: 24px;
background: linear-gradient(180deg, rgba(255,255,255,.11), rgba(255,255,255,.055)), rgba(8, 14, 31, .72);
box-shadow: 0 28px 100px rgba(0, 0, 0, .42), inset 0 1px 0 rgba(255,255,255,.14);
padding: 28px;
backdrop-filter: blur(24px) saturate(140%);
}
.eyebrow { color: #58ddff; font-size: 11px; font-weight: 800; letter-spacing: .18em; text-transform: uppercase; }
h1 { margin: 8px 0 8px; font-size: clamp(28px, 4vw, 44px); line-height: 1; }
p { margin: 0; color: rgba(225, 236, 251, .72); line-height: 1.6; }
.status { margin-top: 20px; display: flex; align-items: center; gap: 10px; font-weight: 800; }
.pulse { width: 10px; height: 10px; border-radius: 50%; background: #64e7bd; box-shadow: 0 0 22px #64e7bd; }
#rdpCanvas { display: none; width: 100vw; height: 100vh; background: #050814; }
</style>
</head>
<body>
<main id="main" class="launch">
<section class="panel">
<span class="eyebrow">POSHManager RDP</span>
<h1>${safeHost}</h1>
<p>Opening a browser RDP session to ${safeAddress} using the assigned host credential. Close this tab to end the session.</p>
<div class="status"><span class="pulse"></span><span id="status">Preparing secure session...</span></div>
</section>
</main>
<canvas id="rdpCanvas"></canvas>
<script>
(function () {
try { window.opener = null; } catch (err) {}
var token = "${safeToken}";
var status = document.getElementById('status');
var canvas = document.getElementById('rdpCanvas');
var main = document.getElementById('main');
var client = Mstsc.client.create(canvas);
function resize() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
}
window.addEventListener('resize', resize);
resize();
status.textContent = 'Connecting...';
canvas.style.display = 'block';
main.style.display = 'none';
client.connect('poshmanager-session', '', '', token, function (err) {
canvas.style.display = 'none';
main.style.display = 'grid';
status.textContent = err ? 'RDP session failed or closed.' : 'RDP session closed.';
});
}());
</script>
</body>
</html>`;
}
export function mountRdpGateway(app) {
app.use('/rdp/assets', express.static(mstscClientDir, {
fallthrough: false,
immutable: true,
maxAge: '1h'
}));
app.get('/rdp/sessions/:token', (req, res) => {
res.redirect(302, `/rdp/${encodeURIComponent(req.params.token)}`);
});
app.get('/rdp/:token', (req, res) => {
cleanupExpiredSessions();
const session = sessions.get(req.params.token);
if (!session) return res.status(404).send('RDP session expired or not found.');
res.type('html').send(renderSessionPage(session));
});
}
export function startRdpGateway(httpServer) {
if (socketServer) return socketServer;
socketServer = socketIo(httpServer, { path: '/rdp/socket.io' });
socketServer.on('connection', (client) => {
let rdpClient = null;
client.on('infos', (infos = {}) => {
cleanupExpiredSessions();
const sessionToken = infos.token || infos.sessionToken || infos.password;
const session = sessions.get(sessionToken);
if (!session) {
client.emit('rdp-error', { code: 'POSHM_RDP_SESSION', message: 'RDP session expired or invalid.' });
client.disconnect();
return;
}
if (rdpClient) rdpClient.close();
const screen = {
width: Math.max(640, Math.min(Number(infos.screen?.width || 1280), 3840)),
height: Math.max(480, Math.min(Number(infos.screen?.height || 800), 2160))
};
logger.info('rdp connection starting', {
hostId: session.hostId,
hostName: session.hostName,
address: session.address,
userId: session.userId
});
rdpClient = rdp.createClient({
domain: session.domain,
userName: session.username,
password: session.password,
enablePerf: true,
autoLogin: true,
screen,
locale: infos.locale,
logLevel: 'ERROR'
}).on('connect', () => {
client.emit('rdp-connect');
logger.info('rdp connection established', { hostId: session.hostId, hostName: session.hostName, userId: session.userId });
}).on('bitmap', (bitmap) => {
client.emit('rdp-bitmap', bitmap);
}).on('close', () => {
client.emit('rdp-close');
sessions.delete(sessionToken);
logger.info('rdp connection closed', { hostId: session.hostId, hostName: session.hostName, userId: session.userId });
}).on('error', (err) => {
client.emit('rdp-error', { code: err.code || 'RDP_ERROR', message: err.message || String(err) });
logger.error('rdp connection failed', { hostId: session.hostId, hostName: session.hostName, userId: session.userId, error: err.message || String(err) });
}).connect(session.address, session.port);
});
client.on('mouse', (x, y, button, isPressed) => {
if (rdpClient) rdpClient.sendPointerEvent(x, y, button, isPressed);
});
client.on('wheel', (x, y, step, isNegative, isHorizontal) => {
if (rdpClient) rdpClient.sendWheelEvent(x, y, step, isNegative, isHorizontal);
});
client.on('scancode', (code, isPressed) => {
if (rdpClient) rdpClient.sendKeyEventScancode(code, isPressed);
});
client.on('unicode', (code, isPressed) => {
if (rdpClient) rdpClient.sendKeyEventUnicode(code, isPressed);
});
client.on('disconnect', () => {
if (rdpClient) rdpClient.close();
});
});
logger.info('POSHManager RDP gateway mounted at /rdp');
return socketServer;
}

View File

@@ -0,0 +1,95 @@
import { RECIPES } from '../data/recipeCatalog.js';
import { analyzeInstaller } from './installerIntelService.js';
import { buildDetectionRule } from './detectionRuleService.js';
// Lists/searches the curated catalog and turns a recipe into the request bodies
// the models accept (PSADT profile + Intune deployment + winget-tracked app).
// installTask type enum is exe|msi|msp|script; everything EXE-based maps to exe.
function taskTypeFor(installerType = '') {
if (installerType === 'msi') return 'msi';
if (installerType === 'msp') return 'msp';
return 'exe';
}
function summarize(recipe) {
return {
id: recipe.id,
vendor: recipe.vendor,
name: recipe.name,
category: recipe.category || 'Other',
installerType: recipe.installerType,
fileName: recipe.fileName,
arch: recipe.arch || 'x64',
installArgs: recipe.installArgs || '',
detectionKind: recipe.detection?.type || '',
wingetId: recipe.wingetId || '',
homepage: recipe.homepage || ''
};
}
export function listRecipes() {
return RECIPES.map(summarize);
}
export function searchRecipes(query = '') {
const q = String(query).trim().toLowerCase();
if (!q) return listRecipes();
return RECIPES.filter((r) =>
[r.vendor, r.name, r.category, r.wingetId, r.id]
.some((field) => String(field || '').toLowerCase().includes(q))
).map(summarize);
}
export function getRecipe(id) {
return RECIPES.find((r) => r.id === id) || null;
}
// recipe → { profile, deployment, application }; application is null with no winget id.
export function buildRecipeArtifacts(recipe, { visibility = 'personal', groupId = null } = {}) {
if (!recipe) throw new Error('recipe is required');
const scope = { visibility, groupId: visibility === 'group' ? groupId || null : null };
const displayName = `${recipe.vendor} ${recipe.name}`.trim();
const analysis = analyzeInstaller({ fileName: recipe.fileName, productCode: recipe.productCode || '' });
const detection = buildDetectionRule(recipe.detection || {});
const profile = {
name: displayName,
appVendor: recipe.vendor,
appName: recipe.name,
appVersion: recipe.version || '',
appArch: recipe.arch === 'x86' ? 'x86' : 'x64',
closeProcesses: recipe.closeProcesses || [],
installTasks: [{
type: taskTypeFor(recipe.installerType),
phase: 'Install',
filePath: recipe.fileName,
arguments: recipe.installArgs || ''
}],
...scope
};
const deployment = {
name: displayName,
appType: 'Windows app (Win32)',
commandStyle: 'v4',
requirements: { architecture: recipe.arch === 'x86' ? 'x86' : 'x64' },
detectionType: detection.detectionType,
detectionRule: detection.detectionRule,
status: 'draft',
notes: `Created from the "${recipe.name}" recipe. Source: ${recipe.homepage || 'n/a'}`,
...scope
};
const application = recipe.wingetId ? {
name: displayName,
vendor: recipe.vendor,
versionSource: 'winget',
versionSourceRef: recipe.wingetId,
autoCheck: true,
notes: `Auto-tracking ${recipe.wingetId} via winget (from the "${recipe.name}" recipe).`,
...scope
} : null;
return { recipeId: recipe.id, displayName, profile, deployment, application, analysis, detection };
}

View File

@@ -35,6 +35,14 @@ export function settingDefinitions() {
{ key: 'entra_password_change_url', value: config.entra.passwordChangeUrl, pinned: envProvided('ENTRA_PASSWORD_CHANGE_URL') },
{ key: 'powershell_bin', value: config.powershellBin, pinned: false },
{ key: 'allow_script_execution', value: String(config.allowScriptExecution), pinned: envProvided('ALLOW_SCRIPT_EXECUTION') },
{ key: 'myrtille_enabled', value: String(config.myrtille.enabled), pinned: envProvided('MYRTILLE_ENABLED') },
{ key: 'myrtille_gateway_url', value: config.myrtille.gatewayUrl, pinned: envProvided('MYRTILLE_GATEWAY_URL') },
{ key: 'myrtille_use_password_hash', value: String(config.myrtille.usePasswordHash), pinned: envProvided('MYRTILLE_USE_PASSWORD_HASH') },
{ key: 'myrtille_allow_plain_password', value: String(config.myrtille.allowPlainPassword), pinned: envProvided('MYRTILLE_ALLOW_PLAIN_PASSWORD') },
{ key: 'myrtille_hash_endpoint', value: config.myrtille.hashEndpoint, pinned: envProvided('MYRTILLE_HASH_ENDPOINT') },
{ key: 'myrtille_default_width', value: String(config.myrtille.defaultWidth), pinned: envProvided('MYRTILLE_DEFAULT_WIDTH') },
{ key: 'myrtille_default_height', value: String(config.myrtille.defaultHeight), pinned: envProvided('MYRTILLE_DEFAULT_HEIGHT') },
{ key: 'myrtille_request_timeout_ms', value: String(config.myrtille.requestTimeoutMs), pinned: envProvided('MYRTILLE_REQUEST_TIMEOUT_MS') },
{ key: 'host_group_sync_interval_minutes', value: process.env.HOST_GROUP_SYNC_INTERVAL_MINUTES || '60', pinned: envProvided('HOST_GROUP_SYNC_INTERVAL_MINUTES') },
{ key: 'runplan_schedule_interval_minutes', value: process.env.RUNPLAN_SCHEDULE_INTERVAL_MINUTES || '1', pinned: envProvided('RUNPLAN_SCHEDULE_INTERVAL_MINUTES') },
{ key: 'vcenter_enabled', value: String(config.vcenter.enabled), pinned: envProvided('VCENTER_ENABLED') },

View File

@@ -6,7 +6,8 @@ const { createHost, listHosts, updateHost, deleteHost, filterVisibleHostIds } =
const { createCredential } = await load('../models/credentialModel.js');
const { createHostGroup, listHostGroups, syncHostGroup } = await load('../models/hostGroupModel.js');
const { createRunPlan, loadRunPlan } = await load('../models/runPlanModel.js');
const { createRdpLaunchSession } = await load('../services/rdpGatewayService.js');
const { createRdpLaunchSession, buildMyrtilleLaunchUrl } = await load('../services/rdpGatewayService.js');
const { updateSettings } = await load('../models/settingsModel.js');
const { db } = await load('../db.js');
const owner = adminUserId();
@@ -54,7 +55,15 @@ test('filterVisibleHostIds drops hosts the user cannot see', () => {
assert.deepEqual(visibleToOther, [shared.id]);
});
test('RDP launch creates a short-lived browser session for Windows hosts with visible credentials', () => {
test('RDP launch creates a Myrtille browser URL for Windows hosts with visible credentials', async () => {
updateSettings({
myrtille_enabled: 'true',
myrtille_gateway_url: 'https://rdp.contoso.local/Myrtille/',
myrtille_use_password_hash: 'false',
myrtille_allow_plain_password: 'true',
myrtille_default_width: '1440',
myrtille_default_height: '900'
});
const credential = createCredential({
name: 'RDP Admin',
kind: 'username_password',
@@ -72,14 +81,28 @@ test('RDP launch creates a short-lived browser session for Windows hosts with vi
tags: [],
visibility: 'shared'
}, owner);
const session = createRdpLaunchSession(host.id, other);
assert.match(session.url, /^\/rdp\/[^/]+$/);
const session = await createRdpLaunchSession(host.id, other);
const url = new URL(session.url);
assert.equal(session.provider, 'myrtille');
assert.equal(url.origin + url.pathname, 'https://rdp.contoso.local/Myrtille/');
assert.equal(url.searchParams.get('server'), 'rdp-win-01.contoso.local');
assert.equal(url.searchParams.get('domain'), 'CONTOSO');
assert.equal(url.searchParams.get('user'), 'rdpadmin');
assert.equal(url.searchParams.get('password'), 'rdp-secret');
assert.equal(url.searchParams.get('width'), '1440');
assert.equal(url.searchParams.get('height'), '900');
assert.equal(url.searchParams.get('connect'), 'Connect!');
assert.equal(session.host.name, 'RDP-WIN-01');
assert.equal(session.host.address, 'rdp-win-01.contoso.local');
assert.ok(!session.url.includes('rdp-secret'));
});
test('RDP launch rejects non-Windows hosts or hosts without credentials', () => {
test('RDP launch rejects non-Windows hosts, hosts without credentials, or disabled Myrtille config', async () => {
updateSettings({
myrtille_enabled: 'true',
myrtille_gateway_url: 'https://rdp.contoso.local/Myrtille/',
myrtille_use_password_hash: 'false',
myrtille_allow_plain_password: 'true'
});
const credential = createCredential({
name: 'RDP Linux Credential',
kind: 'username_password',
@@ -88,10 +111,29 @@ test('RDP launch rejects non-Windows hosts or hosts without credentials', () =>
visibility: 'shared'
}, owner);
const linux = createHost({ name: 'RDP-LINUX-01', address: 'rdp-linux.local', osFamily: 'linux', transport: 'ssh', credentialId: credential.id, tags: [], visibility: 'shared' }, owner);
assert.throws(() => createRdpLaunchSession(linux.id, owner), /Windows hosts/i);
await assert.rejects(() => createRdpLaunchSession(linux.id, owner), /Windows hosts/i);
const noCredential = createHost({ name: 'RDP-NOCRED-01', address: 'rdp-nocred.local', osFamily: 'windows', transport: 'winrm', tags: [], visibility: 'shared' }, owner);
assert.throws(() => createRdpLaunchSession(noCredential.id, owner), /assigned visible credential/i);
await assert.rejects(() => createRdpLaunchSession(noCredential.id, owner), /assigned visible credential/i);
const windows = createHost({ name: 'RDP-DISABLED-01', address: 'rdp-disabled.local', osFamily: 'windows', transport: 'winrm', credentialId: credential.id, tags: [], visibility: 'shared' }, owner);
updateSettings({ myrtille_enabled: 'false' });
await assert.rejects(() => createRdpLaunchSession(windows.id, owner), /Myrtille RDP gateway is not configured/i);
});
test('Myrtille launch URL can carry passwordHash without exposing plaintext', () => {
const url = new URL(buildMyrtilleLaunchUrl({
gatewayUrl: 'https://rdp.contoso.local/Myrtille/',
target: 'win-01.contoso.local',
domain: 'CONTOSO',
username: 'admin',
passwordHash: 'hash-value',
width: 1280,
height: 800
}));
assert.equal(url.searchParams.get('passwordHash'), 'hash-value');
assert.equal(url.searchParams.has('password'), false);
assert.equal(url.searchParams.get('connect'), 'Connect!');
});
test('manual host groups keep explicit members', () => {

View File

@@ -0,0 +1,67 @@
import './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { adminUserId, load } from './setup.mjs';
const owner = adminUserId();
const { createPsadtProfile, renderPsadtProfile } = await load('../models/psadtModel.js');
const { createAssetFromUpload, createAssetLink, listLinkedAssets } = await load('../models/assetModel.js');
const { buildZip, planPackageLayout } = await load('../services/packageBuilder.js');
function seedAsset(name, contents) {
const tmp = path.join(os.tmpdir(), `pkg-src-${Math.random().toString(16).slice(2)}`);
fs.writeFileSync(tmp, contents);
return createAssetFromUpload(
{ path: tmp, originalname: name, mimetype: 'application/octet-stream', size: contents.length },
{ name, visibility: 'shared' },
owner
);
}
test('listLinkedAssets returns a PSADT profile\'s linked assets with their package path and role', () => {
const profile = createPsadtProfile({ name: 'Linked Pkg App', appVendor: 'Acme', appName: 'Tool', visibility: 'shared' }, owner);
const installer = seedAsset('setup.exe', Buffer.from('MZ binary'));
createAssetLink(installer.id, { targetType: 'psadt_profile', targetId: profile.id, linkRole: 'installer', packagePath: 'Files/setup.exe' }, owner);
const linked = listLinkedAssets('psadt_profile', profile.id, owner);
assert.equal(linked.length, 1);
assert.equal(linked[0].assetId, installer.id);
assert.equal(linked[0].linkRole, 'installer');
assert.equal(linked[0].packagePath, 'Files/setup.exe');
});
test('a profile + linked assets assemble into a zip carrying the rendered script and payload', () => {
const profile = createPsadtProfile({ name: 'Assemble App', appVendor: 'Acme', appName: 'Assemble', visibility: 'shared' }, owner);
const installer = seedAsset('install.exe', Buffer.from('payload-bytes'));
const config = seedAsset('settings.xml', Buffer.from('<config/>'));
createAssetLink(installer.id, { targetType: 'psadt_profile', targetId: profile.id, linkRole: 'installer' }, owner);
createAssetLink(config.id, { targetType: 'psadt_profile', targetId: profile.id, linkRole: 'support-file' }, owner);
const rendered = renderPsadtProfile(profile.id, owner);
const layout = planPackageLayout(listLinkedAssets('psadt_profile', profile.id, owner));
assert.deepEqual(layout.assets.map((a) => a.packagePath).sort(), ['Files/install.exe', 'SupportFiles/settings.xml']);
const zip = buildZip([
{ path: 'Invoke-AppDeployToolkit.ps1', data: Buffer.from(rendered.script, 'utf8') },
{ path: 'Files/install.exe', data: Buffer.from('payload-bytes') }
]);
const names = listZipNames(zip);
assert.ok(names.includes('Invoke-AppDeployToolkit.ps1'));
assert.ok(names.includes('Files/install.exe'));
});
function listZipNames(buf) {
const names = [];
let pos = 0;
while (buf.readUInt32LE(pos) === 0x04034b50) {
const compSize = buf.readUInt32LE(pos + 18);
const nameLen = buf.readUInt16LE(pos + 26);
const extraLen = buf.readUInt16LE(pos + 28);
names.push(buf.slice(pos + 30, pos + 30 + nameLen).toString('utf8'));
pos = pos + 30 + nameLen + extraLen + compSize;
}
return names;
}

View File

@@ -0,0 +1,104 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import zlib from 'node:zlib';
import { ENTRY_SCRIPT_NAME, buildZip, planPackageLayout, resolveSetupFile } from '../services/packageBuilder.js';
// --- layout planning (pure) --------------------------------------------------
test('planPackageLayout places linked assets by role when no package path is set', () => {
const layout = planPackageLayout([
{ assetId: 'a1', originalName: 'setup.exe', linkRole: 'installer' },
{ assetId: 'a2', originalName: 'config.xml', linkRole: 'support-file' },
{ assetId: 'a3', originalName: 'icon.png', linkRole: 'icon' }
]);
assert.equal(layout.scriptPath, ENTRY_SCRIPT_NAME);
assert.deepEqual(layout.assets.map((a) => a.packagePath), ['Files/setup.exe', 'SupportFiles/config.xml', 'Assets/icon.png']);
assert.ok(layout.directories.includes('Assets'), 'Assets dir added because an icon is present');
});
test('planPackageLayout honors an explicit package path verbatim', () => {
const layout = planPackageLayout([
{ assetId: 'a1', originalName: 'setup.exe', linkRole: 'installer', packagePath: 'Files/x64/setup.exe' }
]);
assert.equal(layout.assets[0].packagePath, 'Files/x64/setup.exe');
});
test('planPackageLayout has no Assets dir when nothing maps there', () => {
const layout = planPackageLayout([{ assetId: 'a1', originalName: 'setup.exe', linkRole: 'installer' }]);
assert.deepEqual(layout.directories, ['Files', 'SupportFiles']);
});
test('planPackageLayout drops colliding targets with a warning', () => {
const layout = planPackageLayout([
{ assetId: 'a1', originalName: 'setup.exe', linkRole: 'installer', packagePath: 'Files/setup.exe' },
{ assetId: 'a2', originalName: 'other.exe', linkRole: 'installer', packagePath: 'files/SETUP.exe' }
]);
assert.equal(layout.assets.length, 1, 'second asset on the same path is skipped');
assert.equal(layout.warnings.length, 1);
});
test('planPackageLayout relocates a bare entry-script name under its role folder, never overwriting root', () => {
const layout = planPackageLayout([
{ assetId: 'a1', originalName: 'Invoke-AppDeployToolkit.ps1', linkRole: 'reference', packagePath: 'Invoke-AppDeployToolkit.ps1' }
]);
assert.equal(layout.assets[0].packagePath, 'SupportFiles/Invoke-AppDeployToolkit.ps1');
assert.notEqual(layout.assets[0].packagePath, ENTRY_SCRIPT_NAME);
});
// --- setup file resolution (pure) --------------------------------------------
test('resolveSetupFile prefers an explicit choice, then installer role, then first Files entry', () => {
const layout = planPackageLayout([
{ assetId: 'a1', originalName: 'helper.exe', linkRole: 'package-file' },
{ assetId: 'a2', originalName: 'setup.exe', linkRole: 'installer' }
]);
assert.equal(resolveSetupFile(layout, 'Files/manual.exe'), 'Files/manual.exe');
assert.equal(resolveSetupFile(layout), 'Files/setup.exe');
const noInstaller = planPackageLayout([{ assetId: 'a1', originalName: 'helper.exe', linkRole: 'package-file' }]);
assert.equal(resolveSetupFile(noInstaller), 'Files/helper.exe');
const empty = planPackageLayout([]);
assert.equal(resolveSetupFile(empty), '');
});
// --- zip writer (pure, round-trips through zlib) -----------------------------
test('buildZip produces a valid archive whose entries inflate back to their input', () => {
const script = 'Write-Output "hello"\n'.repeat(50);
const binary = Buffer.from([0, 1, 2, 3, 255, 254, 253]);
const zip = buildZip([
{ path: ENTRY_SCRIPT_NAME, data: Buffer.from(script, 'utf8') },
{ path: 'Files', isDirectory: true },
{ path: 'Files/payload.bin', data: binary }
]);
assert.equal(zip.readUInt32LE(0), 0x04034b50, 'starts with a local file header');
const recovered = readZip(zip);
assert.equal(recovered.get(ENTRY_SCRIPT_NAME).toString('utf8'), script);
assert.ok(recovered.has('Files/'), 'directory entry is present');
assert.deepEqual([...recovered.get('Files/payload.bin')], [...binary]);
});
test('buildZip stores incompressible/empty data without inflating it', () => {
const zip = buildZip([{ path: 'a.txt', data: Buffer.alloc(0) }]);
assert.equal(readZip(zip).get('a.txt').length, 0);
});
// Minimal central-directory reader for the test: walk local headers and inflate.
function readZip(buf) {
const out = new Map();
let pos = 0;
while (buf.readUInt32LE(pos) === 0x04034b50) {
const method = buf.readUInt16LE(pos + 8);
const compSize = buf.readUInt32LE(pos + 18);
const nameLen = buf.readUInt16LE(pos + 26);
const extraLen = buf.readUInt16LE(pos + 28);
const name = buf.slice(pos + 30, pos + 30 + nameLen).toString('utf8');
const dataStart = pos + 30 + nameLen + extraLen;
const stored = buf.slice(dataStart, dataStart + compSize);
out.set(name, method === 8 ? zlib.inflateRawSync(stored) : stored);
pos = dataStart + compSize;
}
return out;
}

View File

@@ -0,0 +1,95 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildDatasheetModel, renderDatasheet } from '../services/packageDocService.js';
function sampleDeployment(overrides = {}) {
return {
id: 'intune_1',
name: 'Acme Reader 24.1',
profileId: 'psadt_1',
profileName: 'Acme Reader',
applicationId: null,
appType: 'Windows app (Win32)',
commandStyle: 'v4',
installBehavior: 'system',
restartBehavior: 'return-code',
sourceFolder: 'C:\\packages\\reader',
intunewinFile: 'reader.intunewin',
installCommand: 'Invoke-AppDeployToolkit.exe -DeploymentType Install',
uninstallCommand: 'Invoke-AppDeployToolkit.exe -DeploymentType Uninstall',
detectionType: 'msi-product-code',
detectionRule: '{12345678-ABCD-1234-ABCD-1234567890AB}',
requirements: { architecture: 'x64', minOs: 'Windows 10 22H2', diskSpaceMb: 250, runAs32Bit: false },
returnCodes: [
{ code: 0, type: 'success', meaning: 'Install completed.' },
{ code: 3010, type: 'softReboot', meaning: 'Reboot required.' }
],
assignments: [
{ ring: 'Pilot', intent: 'available', groupName: 'IT-Pilot', notes: 'Validation ring.' }
],
status: 'draft',
notes: 'Created from the recipe.',
...overrides
};
}
const sampleProfile = {
name: 'Acme Reader',
appVendor: 'Acme',
appName: 'Reader',
appVersion: '24.1',
appArch: 'x64'
};
test('buildDatasheetModel pulls metadata from the profile and lays out every section', () => {
const model = buildDatasheetModel({ deployment: sampleDeployment(), profile: sampleProfile });
assert.equal(model.title, 'Acme Reader 24.1');
assert.equal(model.subtitle, 'Acme Reader 24.1');
const headings = model.sections.map((s) => s.heading);
assert.deepEqual(headings, ['Overview', 'Install & uninstall', 'Detection', 'Return codes', 'Assignments', 'Requirements', 'Source & version', 'Notes']);
});
test('buildDatasheetModel requires a deployment', () => {
assert.throws(() => buildDatasheetModel({}), /deployment is required/);
});
test('buildDatasheetModel omits the Notes section when there are no notes', () => {
const model = buildDatasheetModel({ deployment: sampleDeployment({ notes: '' }), profile: sampleProfile });
assert.ok(!model.sections.some((s) => s.heading === 'Notes'));
});
test('renderDatasheet markdown carries commands, detection, return codes and assignments', () => {
const doc = renderDatasheet({ deployment: sampleDeployment(), profile: sampleProfile }, { format: 'md' });
assert.equal(doc.format, 'md');
assert.equal(doc.extension, 'md');
assert.match(doc.contentType, /text\/markdown/);
assert.match(doc.content, /^# Acme Reader 24\.1/);
assert.match(doc.content, /Invoke-AppDeployToolkit\.exe -DeploymentType Install/);
assert.match(doc.content, /\{12345678-ABCD-1234-ABCD-1234567890AB\}/);
assert.match(doc.content, /\| 3010 \| Soft reboot \| Reboot required\. \|/);
assert.match(doc.content, /\| Pilot \| Available \| IT-Pilot \| Validation ring\. \|/);
});
test('renderDatasheet html is self-contained and escapes user content', () => {
const doc = renderDatasheet(
{ deployment: sampleDeployment({ notes: 'Watch <script> & "quotes"' }), profile: sampleProfile },
{ format: 'html' }
);
assert.equal(doc.format, 'html');
assert.equal(doc.extension, 'html');
assert.match(doc.contentType, /text\/html/);
assert.match(doc.content, /^<!doctype html>/);
assert.match(doc.content, /Watch &lt;script&gt; &amp; &quot;quotes&quot;/);
assert.ok(!doc.content.includes('<script>'), 'no unescaped script tag leaks through');
});
test('renderDatasheet shows empty tables gracefully and defaults to markdown', () => {
const doc = renderDatasheet({ deployment: sampleDeployment({ assignments: [], returnCodes: [] }) });
assert.equal(doc.format, 'md');
assert.match(doc.content, /_None_/);
});
test('renderDatasheet falls back to the deployment name without a profile', () => {
const doc = renderDatasheet({ deployment: sampleDeployment({ profileId: null }) }, { format: 'md' });
assert.match(doc.content, /# Acme Reader 24\.1/);
});

View File

@@ -0,0 +1,92 @@
import './setup.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { load } from './setup.mjs';
const { listRecipes, searchRecipes, getRecipe, buildRecipeArtifacts } =
await load('../services/recipeService.js');
// --- catalog -----------------------------------------------------------------
test('listRecipes exposes a sanitized catalog with the fields the UI needs', () => {
const list = listRecipes();
assert.ok(list.length >= 10, 'catalog has a useful number of recipes');
const chrome = list.find((r) => r.id === 'google-chrome');
assert.ok(chrome);
assert.equal(chrome.wingetId, 'Google.Chrome');
assert.ok(chrome.installArgs.includes('/qn'));
assert.ok(chrome.homepage.startsWith('https://'));
});
test('every recipe references a known installer technology and a valid detection footprint', async () => {
const { listInstallerTechnologies } = await load('../services/installerIntelService.js');
const techIds = new Set(listInstallerTechnologies().map((t) => t.id));
for (const r of listRecipes()) {
assert.ok(techIds.has(r.installerType), `${r.id} -> ${r.installerType} is a known technology`);
// Detection must not throw when applied (file/registry/msi all valid here).
assert.doesNotThrow(() => buildRecipeArtifacts(getRecipe(r.id)), `${r.id} detection builds`);
}
});
test('enterprise agents without a winget id still build, but skip the catalog app', () => {
const falcon = getRecipe('crowdstrike-falcon');
assert.equal(falcon.wingetId, '');
const { application, deployment } = buildRecipeArtifacts(falcon);
assert.equal(application, null, 'no winget id -> no auto-tracked app');
assert.ok(deployment.detectionRule.includes('CSFalconService.exe'));
});
test('searchRecipes matches vendor, name, and winget id case-insensitively', () => {
assert.ok(searchRecipes('mozilla').some((r) => r.id === 'mozilla-firefox'));
assert.ok(searchRecipes('VLC').some((r) => r.id === 'vlc'));
assert.ok(searchRecipes('Microsoft.PowerShell').some((r) => r.id === 'powershell-7'));
assert.equal(searchRecipes('nonexistent-app-xyz').length, 0);
assert.equal(searchRecipes('').length, listRecipes().length);
});
// --- apply transform (pure) --------------------------------------------------
test('buildRecipeArtifacts produces profile + deployment + tracked app for an MSI recipe', () => {
const { profile, deployment, application } = buildRecipeArtifacts(getRecipe('google-chrome'));
// Profile carries the raw silent install as a PSADT install task.
assert.equal(profile.name, 'Google Chrome');
assert.equal(profile.appVendor, 'Google');
assert.equal(profile.installTasks.length, 1);
assert.equal(profile.installTasks[0].type, 'msi');
assert.equal(profile.installTasks[0].filePath, 'googlechromestandaloneenterprise64.msi');
assert.ok(profile.installTasks[0].arguments.includes('/qn'));
// Deployment wraps it in PSADT v4 with a file-detection rule.
assert.equal(deployment.commandStyle, 'v4');
assert.equal(deployment.detectionType, 'custom-script');
assert.ok(deployment.detectionRule.includes('chrome.exe'));
assert.equal(deployment.status, 'draft');
// Catalog app is wired to the winget version watcher.
assert.equal(application.versionSource, 'winget');
assert.equal(application.versionSourceRef, 'Google.Chrome');
assert.equal(application.autoCheck, true);
});
test('EXE-based recipes become exe install tasks with the silent switch', () => {
const { profile } = buildRecipeArtifacts(getRecipe('git'));
assert.equal(profile.installTasks[0].type, 'exe');
assert.ok(profile.installTasks[0].arguments.includes('/VERYSILENT'));
});
test('applying with group visibility scopes the group id', () => {
const { profile, deployment } = buildRecipeArtifacts(getRecipe('7zip'), { visibility: 'group', groupId: 'grp_1' });
assert.equal(profile.visibility, 'group');
assert.equal(profile.groupId, 'grp_1');
assert.equal(deployment.groupId, 'grp_1');
});
test('group id is dropped when visibility is not group', () => {
const { profile } = buildRecipeArtifacts(getRecipe('7zip'), { visibility: 'personal', groupId: 'grp_1' });
assert.equal(profile.groupId, null);
});
test('buildRecipeArtifacts throws on a missing recipe', () => {
assert.throws(() => buildRecipeArtifacts(null));
});

View File

@@ -20,12 +20,6 @@ export default defineConfig({
target: apiTarget,
changeOrigin: true,
secure: false
},
'/rdp': {
target: apiTarget,
changeOrigin: true,
secure: false,
ws: true
}
}
},
@@ -37,12 +31,6 @@ export default defineConfig({
target: apiTarget,
changeOrigin: true,
secure: false
},
'/rdp': {
target: apiTarget,
changeOrigin: true,
secure: false,
ws: true
}
}
},