chore: 저장소 구조 정리 및 문서화, 첫 커밋

- src/dist 산출물 분리 원칙 정리(.gitignore, .gitattributes)
- 루트 및 주요 폴더(config/scripts/prompts/tests/src, 런타임 폴더 5종)에
  안내용 README.md 추가
- CHANGELOG.md, LICENSE, docs/ops/05-release-and-versioning.md 추가
- docs/README.md 문서 지도 갱신
This commit is contained in:
Yun Chan 2026-09-04 09:25:44 +09:00
commit 56a6e2da93
159 changed files with 145825 additions and 0 deletions

15
scripts/README.md Normal file
View file

@ -0,0 +1,15 @@
# `scripts/` — 설치·운영 보조 스크립트
전부 PowerShell(`.ps1`)이다. **`.ps1` 파일은 반드시 UTF-8 with BOM으로 저장한다.**
BOM이 없으면 Windows PowerShell 5.1이 CP949로 잘못 읽어 한글 문구가 깨진다
(`AGENTS.md` §6 실측 함정). 이 스크립트들을 고칠 때는 저장 후 인코딩을 확인한다.
| 스크립트 | 언제 실행되는가 | 역할 |
|---|---|---|
| `install_tasks.ps1` | `bootstrap.cmd` 또는 온보딩 GUI의 [작업 다시 등록] | Windows 작업 스케줄러에 Daily/로그온 에이전트/AGY 주간 업데이트 3개 작업을 idempotent하게 등록 |
| `uninstall_tasks.ps1` | 수동 실행(제거 시) | 위 작업 3개를 스케줄러에서 제거 |
| `make_shortcuts.ps1` | `bootstrap.cmd` | 바탕화면에 "DMF 설정", "지금 실행" 바로가기 생성(`pythonw.exe` 대상, 콘솔 창 없음) |
| `bootstrap_agy.ps1` | `agy` 설치가 필요할 때 | `agy` 존재 확인 → 없으면 공식 설치 스크립트를 무인 실행 → 버전 출력 |
각 스크립트가 정확히 어떤 인자·전제조건·실패 처리를 갖는지는
`docs/ops/01-scheduling-and-resilience.md`가 정본이다.

149
scripts/bootstrap_agy.ps1 Normal file
View file

@ -0,0 +1,149 @@
#Requires -Version 5.1
<#
.SYNOPSIS
agy.exe 존재를 확인하고, 없으면 공식 install.ps1 무인 설치한다.
.DESCRIPTION
1순위: 공식 설치 스크립트 https://antigravity.google/cli/install.ps1
(SHA512 무결성 검증 내장, 이미 설치돼 있으면 아무 것도 하지 않고
종료 코드 0 으로 빠지는 멱등한 스크립트 agy CLI SSOT §3.3 실측).
2순위: winget (1순위가 실패했을 때만 시도).
설치 여부와 별개로 "인증(로그인) 여부" 가볍게 점검한다. agy OAuth
토큰을 사용자 프로필 아래 평문 파일로 저장한다(agy CLI SSOT §4.2 실측:
~\.gemini\antigravity-cli\antigravity-oauth-token). 파일의 존재만
확인하고, 실제로 토큰이 유효한지 확인하는 헤드리스 왕복 호출은 하지
않는다 호출 자체가 수만 토큰 비용을 태우므로(SSOT §4.3 경고),
설치 스크립트 안에서 매번 돌릴 일이 아니다. 진짜 유효성 판정은 실제
작업 호출의 성공/실패로 한다(agy/client.py 책임).
.PARAMETER Force
이미 정상 설치되어 있어도 재설치를 시도한다.
.OUTPUTS
표준 출력에 사람이 읽을 진행 메시지, 마지막 줄에 "VERSION=<버전>"
(설치가 확인됐을 때만) GUI/CLI 파싱하고 싶으면 줄만 보면 된다.
.EXITCODE
0 : 정상 agy.exe 설치 확인 + 인증 토큰 파일 존재.
10 : 설치 필요 agy.exe ()설치하지 못했다.
11 : 인증 필요 agy.exe 설치돼 있지만 로그인 토큰 파일이 없다.
`agy login` 사용자가 직접 수행해야 한다(대화형 브라우저 로그인이라
무인화할 없다 agy CLI SSOT §4.1).
.EXAMPLE
powershell -ExecutionPolicy Bypass -File .\scripts\bootstrap_agy.ps1
#>
[CmdletBinding()]
param(
[switch]$Force
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Continue'
function Write-Step { param([string]$Message) Write-Host "[bootstrap_agy] $Message" }
function Write-Warn { param([string]$Message) Write-Host "[bootstrap_agy] ! $Message" -ForegroundColor Yellow }
function Write-Good { param([string]$Message) Write-Host "[bootstrap_agy] + $Message" -ForegroundColor Green }
# 설치·점검 도중 백그라운드 self-update 가 끼어들면 바이너리가 교체돼
# 검증이 흔들린다. 이 프로세스 범위에서만 끈다(agy CLI SSOT §3.6).
$env:AGY_CLI_DISABLE_AUTO_UPDATE = 'true'
# 절대경로 고정 — where.exe 는 winget Links 심볼릭까지 잡아 두 경로가
# 나올 수 있으므로(agy CLI SSOT §3.2 실측) 신뢰하지 않는다.
$agyExe = Join-Path $env:LOCALAPPDATA 'agy\bin\agy.exe'
$agyToken = Join-Path $env:USERPROFILE '.gemini\antigravity-cli\antigravity-oauth-token'
function Test-AgyOk {
<#
agy.exe 존재하고 실제로 실행되는지 확인한다.
0바이트 스텁·중단된 다운로드를 걸러낸다(실측 정상 크기 187MB).
성공하면 버전 문자열을, 실패하면 $null 돌려준다.
#>
if (-not (Test-Path -LiteralPath $agyExe)) { return $null }
$item = Get-Item -LiteralPath $agyExe -ErrorAction SilentlyContinue
if (-not $item -or $item.Length -lt 1MB) { return $null }
try {
$v = & $agyExe --version 2>$null
} catch {
return $null
}
if ($LASTEXITCODE -ne 0 -or -not $v) { return $null }
return ($v | Select-Object -First 1).ToString().Trim()
}
function Test-AgyAuthenticated {
<#
인증 여부의 가벼운(비용 없는) 근사치: 토큰 파일 존재 여부.
실제 유효성(만료 여부)까지는 확인하지 않는다 §4.3 경고 참조.
#>
return (Test-Path -LiteralPath $agyToken)
}
Write-Step "확인 경로: $agyExe"
$version = Test-AgyOk
if ($version -and -not $Force) {
Write-Good "이미 설치되어 있습니다. (버전 $version)"
} else {
if ($Force) {
Write-Step '재설치를 요청받았습니다. 설치를 진행합니다.'
} else {
Write-Step '설치되어 있지 않거나 손상된 것으로 보입니다. 설치를 진행합니다.'
}
# ------------------------------------------------------------ 1순위: 공식 설치 스크립트
Write-Step '공식 설치 스크립트를 받는 중... (irm https://antigravity.google/cli/install.ps1 | iex)'
try {
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
} catch {
Write-Warn 'TLS 1.2 강제 설정에 실패했습니다. 기본 프로토콜로 계속합니다.'
}
try {
Invoke-Expression (Invoke-RestMethod -Uri 'https://antigravity.google/cli/install.ps1' -UseBasicParsing)
} catch {
Write-Warn "공식 설치 스크립트 실행 실패: $($_.Exception.Message)"
}
$version = Test-AgyOk
if ($version) {
Write-Good "설치 확인: 버전 $version"
} else {
# -------------------------------------------------------- 2순위: winget
Write-Step '1순위 설치가 확인되지 않았습니다. winget 으로 다시 시도합니다...'
$winget = Get-Command winget.exe -ErrorAction SilentlyContinue
if ($winget) {
try {
& $winget.Source install --id Google.AntigravityCLI --silent `
--accept-package-agreements --accept-source-agreements | Out-Null
} catch {
Write-Warn "winget 설치 실패: $($_.Exception.Message)"
}
} else {
Write-Warn 'winget 을 사용할 수 없습니다.'
}
$version = Test-AgyOk
if ($version) {
Write-Good "설치 확인: 버전 $version"
}
}
}
if (-not $version) {
Write-Warn '설치에 실패했습니다. 네트워크 연결 또는 방화벽 설정을 확인해 주세요.'
Write-Warn " 수동 설치: irm https://antigravity.google/cli/install.ps1 | iex"
exit 10
}
Write-Host "VERSION=$version"
if (-not (Test-AgyAuthenticated)) {
Write-Warn '로그인 토큰을 찾을 수 없습니다. 로그인이 필요합니다.'
Write-Warn " 다음 명령을 배치를 돌릴 그 계정으로 실행하세요:"
Write-Warn " & `"$agyExe`" login"
exit 11
}
Write-Good '설치와 로그인 토큰 확인이 모두 끝났습니다.'
exit 0

509
scripts/install_tasks.ps1 Normal file
View file

@ -0,0 +1,509 @@
#Requires -Version 5.1
<#
.SYNOPSIS
DMF Crawler 작업 스케줄러 작업 3종(Daily / Agent / AgyUpdate) 등록한다.
.DESCRIPTION
멱등(idempotent)하다. 같은 이름의 작업이 이미 있으면 지우고 다시 만든다.
관리자 권한 PowerShell 에서 실행해야 한다(RunLevel Highest 등록에 필요
사용자 확정: 관리자 승격은 설치 시점 1회만 요구한다).
트리거 3종(사용자 확정 사항, ops/01-scheduling-and-resilience.md §1.4):
매일 지정 시각(기본 06:00, RandomDelay 지터) schedule.enable_daily_trigger
부팅 캐치업(StartWhenAvailable + AtStartup, RestartCount 재시도 포함)
schedule.enable_missed_task_catchup
로그온 알림 에이전트 schedule.enable_logon_trigger
토글은 config.toml [schedule] 섹션과 이름을 맞췄다. 스위치를 끄면
해당 트리거만 빠지고 나머지는 그대로 등록된다.
배치(DMF_Crawler_Daily) UI 띄우는 에이전트(DMF_Crawler_Agent)
분리한다(ADR-10) S4U 세션에는 데스크톱이 없어 토스트·모달을 띄울
없기 때문이다. 알림을 "발생"시키는 주체와 "표시"하는 주체가 다르다.
.PARAMETER ProjectRoot
프로젝트 루트. 생략하면 스크립트 위치의 상위 폴더.
.PARAMETER TaskPath
작업 스케줄러 폴더. 기본 '\DMF_Crawler\' (아키텍처 §4 데이터 흐름의
DMF_Crawler_Daily 표기와 일치시킨다).
.PARAMETER Time
schedule.daily_time 대응. 기본 06:00.
.PARAMETER User
작업을 실행할 계정. 기본은 현재 로그인 계정.
.PARAMETER LogonType
S4U(기본, 암호 저장 ) | Password(DPAPI 복호화가 S4U 에서 실패할 ) |
Interactive('Logon as Batch' 권한이 없는 계정의 최후 폴백).
.PARAMETER JitterSeconds
schedule.jitter_seconds 대응. 0~300초.
.PARAMETER StartupDelayMinutes
schedule.startup_delay_minutes 대응. 부팅 트리거 지연.
.PARAMETER ExecutionTimeLimitMinutes
schedule.execution_time_limit_minutes 대응.
.PARAMETER RestartCount
schedule.restart_count 대응.
.PARAMETER RestartIntervalMinutes
schedule.restart_interval_minutes 대응.
.PARAMETER AgentRepeatMinutes
schedule.agent_repeat_minutes 대응. 알림 에이전트 반복 주기.
.PARAMETER AgyUpdateWeekday
schedule.agy_update_weekday 대응.
.PARAMETER AgyUpdateTime
schedule.agy_update_time 대응.
.PARAMETER EnableDailyTrigger
schedule.enable_daily_trigger 대응. 끄면 매일 06:00 자동 실행이 빠진다
(수동 실행·부팅 캐치업은 별개로 남는다).
.PARAMETER EnableLogonTrigger
schedule.enable_logon_trigger 대응. 끄면 로그온 즉시 실행이 빠진다
(15분 반복 자체는 남는다 로그온한 세션이 있어야 어차피 돈다).
.PARAMETER EnableMissedTaskCatchup
schedule.enable_missed_task_catchup 대응. 끄면 AtStartup 트리거와
StartWhenAvailable/WakeToRun 빠진다.
.PARAMETER PreventConcurrentRuns
schedule.prevent_concurrent_runs 대응. 끄면 스케줄러 레벨의
MultipleInstances IgnoreNew 대신 Parallel 된다(코드의 idempotency
가드·run.lock 스위치와 무관하게 항상 동작한다 3중 방어의
나머지 2겹).
.PARAMETER SkipAgyUpdateTask
agy.exe 없을 자동으로 건너뛰지만, 있어도 강제로 건너뛰고 싶을 .
.PARAMETER Verify
등록 직후 보안 컨텍스트 프로브(`dmf_crawler doctor --json`) 1회
실행해 DPAPI 복호화가 LogonType 에서 되는지 실측한다.
.EXAMPLE
powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -Verify
.EXAMPLE
# DPAPI 복호화가 S4U 에서 실패했을 때
powershell -ExecutionPolicy Bypass -File .\scripts\install_tasks.ps1 -LogonType Password -Verify
#>
[CmdletBinding()]
param(
[string]$ProjectRoot = (Split-Path -Parent $PSScriptRoot),
[string]$TaskPath = '\DMF_Crawler\',
[string]$Time = '06:00',
[string]$User = "$env:USERDOMAIN\$env:USERNAME",
[ValidateSet('S4U', 'Password', 'Interactive')]
[string]$LogonType = 'S4U',
[System.Security.SecureString]$Password,
[ValidateRange(0, 300)]
[int]$JitterSeconds = 240,
[ValidateRange(0, 120)]
[int]$StartupDelayMinutes = 5,
[ValidateRange(5, 720)]
[int]$ExecutionTimeLimitMinutes = 30,
[ValidateRange(0, 10)]
[int]$RestartCount = 3,
[ValidateRange(1, 120)]
[int]$RestartIntervalMinutes = 10,
[ValidateRange(1, 1440)]
[int]$AgentRepeatMinutes = 15,
[ValidateSet('Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday')]
[string]$AgyUpdateWeekday = 'Sunday',
[string]$AgyUpdateTime = '14:00',
[bool]$EnableDailyTrigger = $true,
[bool]$EnableLogonTrigger = $true,
[bool]$EnableMissedTaskCatchup = $true,
[bool]$PreventConcurrentRuns = $true,
[switch]$SkipAgyUpdateTask,
[switch]$Verify
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ---------------------------------------------------------------- 유틸
function Write-Step { param([string]$Message) Write-Host "[install_tasks] $Message" }
function Write-Warn { param([string]$Message) Write-Host "[install_tasks] ! $Message" -ForegroundColor Yellow }
function Write-Good { param([string]$Message) Write-Host "[install_tasks] + $Message" -ForegroundColor Green }
function Assert-Administrator {
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
$pr = [Security.Principal.WindowsPrincipal]::new($id)
if (-not $pr.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw '관리자 권한 PowerShell 에서 실행하세요. (시작 → PowerShell 우클릭 → 관리자 권한으로 실행)'
}
}
function ConvertTo-PlainText {
param([System.Security.SecureString]$Secure)
if (-not $Secure) { return $null }
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure)
try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) }
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
}
function Get-TimeOfDay {
param([string]$Text, [string]$Label)
$parsed = [datetime]::MinValue
$ok = [datetime]::TryParseExact(
$Text, 'HH:mm',
[Globalization.CultureInfo]::InvariantCulture,
[Globalization.DateTimeStyles]::None,
[ref]$parsed)
if (-not $ok) { throw "$Label 형식이 잘못됐습니다: '$Text' (HH:mm 이어야 합니다)" }
return (Get-Date).Date.AddHours($parsed.Hour).AddMinutes($parsed.Minute)
}
# ---------------------------------------------------------------- 0. 사전 점검
Assert-Administrator
$ProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot).Path
$PythonExe = Join-Path $ProjectRoot '.venv\Scripts\python.exe'
$PythonwExe = Join-Path $ProjectRoot '.venv\Scripts\pythonw.exe'
$AgyExe = Join-Path $env:LOCALAPPDATA 'agy\bin\agy.exe'
Write-Step "프로젝트 루트 : $ProjectRoot"
Write-Step "실행 계정 : $User (LogonType=$LogonType)"
foreach ($exe in @($PythonExe, $PythonwExe)) {
if (-not (Test-Path -LiteralPath $exe)) {
throw "가상환경 실행 파일이 없습니다: $exe`n → bootstrap.cmd 를 먼저 실행하세요."
}
}
if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot 'config\config.toml'))) {
Write-Warn 'config\config.toml 이 없습니다. 등록은 진행하지만 첫 실행은 종료 코드 2(BLOCKED)로 끝납니다.'
}
foreach ($dir in @('state', 'logs', 'reports', 'data')) {
$p = Join-Path $ProjectRoot $dir
if (-not (Test-Path -LiteralPath $p)) { New-Item -ItemType Directory -Path $p | Out-Null }
}
$plainPassword = $null
if ($LogonType -eq 'Password') {
if (-not $Password) {
$Password = Read-Host -AsSecureString "«$User» 계정의 Windows 로그인 암호"
}
$plainPassword = ConvertTo-PlainText -Secure $Password
if ([string]::IsNullOrEmpty($plainPassword)) { throw '암호가 비어 있습니다.' }
}
$multipleInstances = if ($PreventConcurrentRuns) { 'IgnoreNew' } else { 'Parallel' }
# ---------------------------------------------------------------- 1. 작업 기록(History) 채널 활성화
# 기본적으로 꺼져 있다. 꺼져 있으면 "기록" 탭이 비고 사후 진단이 불가능하다.
Write-Step '작업 스케줄러 Operational 로그 활성화'
try {
& wevtutil.exe set-log 'Microsoft-Windows-TaskScheduler/Operational' /enabled:true /quiet
& wevtutil.exe set-log 'Microsoft-Windows-TaskScheduler/Operational' /maxsize:67108864
if ($LASTEXITCODE -ne 0) { Write-Warn "wevtutil 이 $LASTEXITCODE 로 끝났습니다. 기록 없이 진행합니다." }
} catch {
Write-Warn "wevtutil 호출 실패(무해): $($_.Exception.Message)"
}
# ---------------------------------------------------------------- 2. 공통 등록 함수
function Register-DmfTask {
<#
$Trigger 배열이면 트리거 없이(수동 실행 전용) 등록한다
-Trigger 파라미터에 배열을 넘기면 오류가 나므로 조건부로 뺀다.
#>
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)]$Action,
[AllowEmptyCollection()][array]$Trigger = @(),
[Parameter(Mandatory)]$Settings,
[Parameter(Mandatory)]$Principal,
[Parameter(Mandatory)][string]$Description,
[string]$PlainPassword
)
$existing = Get-ScheduledTask -TaskName $Name -TaskPath $Path -ErrorAction SilentlyContinue
if ($existing) {
Write-Step "기존 작업 제거: $Path$Name"
Unregister-ScheduledTask -TaskName $Name -TaskPath $Path -Confirm:$false
}
$newTaskArgs = @{
Action = $Action
Settings = $Settings
Principal = $Principal
Description = $Description
}
if ($Trigger.Count -gt 0) { $newTaskArgs['Trigger'] = $Trigger }
$definition = New-ScheduledTask @newTaskArgs
if ($PlainPassword) {
Register-ScheduledTask -TaskName $Name -TaskPath $Path -InputObject $definition `
-User $Principal.UserId -Password $PlainPassword | Out-Null
} else {
Register-ScheduledTask -TaskName $Name -TaskPath $Path -InputObject $definition | Out-Null
}
Write-Good "등록 완료: $Path$Name"
}
# ---------------------------------------------------------------- 3. ① DMF_Crawler_Daily
Write-Step '① DMF_Crawler_Daily 구성'
$actionDaily = New-ScheduledTaskAction `
-Execute $PythonExe `
-Argument '-m dmf_crawler run --trigger scheduled' `
-WorkingDirectory $ProjectRoot
$dailyTriggers = @()
if ($EnableDailyTrigger) {
$dailyAt = Get-TimeOfDay -Text $Time -Label 'schedule.daily_time'
$trgDaily = New-ScheduledTaskTrigger -Daily -At $dailyAt `
-RandomDelay (New-TimeSpan -Seconds $JitterSeconds)
$dailyTriggers += $trgDaily
} else {
Write-Warn 'schedule.enable_daily_trigger=false → 매일 자동 실행 트리거를 등록하지 않습니다.'
}
if ($EnableMissedTaskCatchup) {
# AtStartup 트리거에는 -Delay 파라미터가 없다. CIM 인스턴스 속성을 직접 채운다.
$trgBoot = New-ScheduledTaskTrigger -AtStartup
$trgBoot.Delay = "PT${StartupDelayMinutes}M"
$dailyTriggers += $trgBoot
} else {
Write-Warn 'schedule.enable_missed_task_catchup=false → 부팅 캐치업 트리거를 등록하지 않습니다.'
}
$setDaily = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-StartWhenAvailable:$EnableMissedTaskCatchup `
-DontStopOnIdleEnd `
-RunOnlyIfNetworkAvailable `
-ExecutionTimeLimit (New-TimeSpan -Minutes $ExecutionTimeLimitMinutes) `
-RestartCount $RestartCount `
-RestartInterval (New-TimeSpan -Minutes $RestartIntervalMinutes) `
-MultipleInstances $multipleInstances `
-Priority 5 `
-Compatibility Win8
if ($EnableMissedTaskCatchup) { $setDaily.WakeToRun = $true }
$prcDaily = New-ScheduledTaskPrincipal -UserId $User -LogonType $LogonType -RunLevel Highest
Register-DmfTask `
-Name 'DMF_Crawler_Daily' `
-Path $TaskPath `
-Action $actionDaily `
-Trigger $dailyTriggers `
-Settings $setDaily `
-Principal $prcDaily `
-Description "DMF 일일 수집·비교·리포트 배치. 매일 $Time + 부팅 후 ${StartupDelayMinutes}분. UI 를 띄우지 않는다." `
-PlainPassword $plainPassword
# ---------------------------------------------------------------- 4. ② DMF_Crawler_Agent
Write-Step '② DMF_Crawler_Agent 구성'
$actionAgent = New-ScheduledTaskAction `
-Execute $PythonwExe `
-Argument '-m dmf_crawler notify-pump --once' `
-WorkingDirectory $ProjectRoot
# 15분 반복은 항상 켠다 — 로그온 세션이 있어야만 Interactive 작업이 도니까
# "로그온 여부와 무관하게 반복 트리거를 심어 둔다"가 안전한 기본값이다.
$repeatStart = (Get-Date).AddMinutes(2)
try {
$trgRepeat = New-ScheduledTaskTrigger -Once -At $repeatStart `
-RepetitionInterval (New-TimeSpan -Minutes $AgentRepeatMinutes) `
-RepetitionDuration ([TimeSpan]::MaxValue)
} catch {
# 일부 빌드에서 [TimeSpan]::MaxValue 가 거부된다. 10년으로 대체한다.
Write-Warn 'RepetitionDuration=MaxValue 거부됨 → 3650일로 대체'
$trgRepeat = New-ScheduledTaskTrigger -Once -At $repeatStart `
-RepetitionInterval (New-TimeSpan -Minutes $AgentRepeatMinutes) `
-RepetitionDuration (New-TimeSpan -Days 3650)
}
$agentTriggers = @($trgRepeat)
if ($EnableLogonTrigger) {
$trgLogon = New-ScheduledTaskTrigger -AtLogOn -User $User
# 로그온 트리거에도 같은 반복을 붙여 둔다(로그온 이후에도 계속 돌게).
$trgLogon.Repetition = $trgRepeat.Repetition
$agentTriggers += $trgLogon
} else {
Write-Warn 'schedule.enable_logon_trigger=false → 로그온 즉시 실행 트리거를 등록하지 않습니다(15분 반복은 유지).'
}
$setAgent = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-DontStopOnIdleEnd `
-ExecutionTimeLimit (New-TimeSpan -Minutes 10) `
-MultipleInstances $multipleInstances `
-Priority 7 `
-Compatibility Win8
# Interactive 는 로그온한 세션에서만 돈다 — 그것이 목적이다(UI 를 띄우는 유일한 작업).
$prcAgent = New-ScheduledTaskPrincipal -UserId $User -LogonType Interactive -RunLevel Limited
Register-DmfTask `
-Name 'DMF_Crawler_Agent' `
-Path $TaskPath `
-Action $actionAgent `
-Trigger $agentTriggers `
-Settings $setAgent `
-Principal $prcAgent `
-Description "DMF 알림 에이전트. 로그온 시 + ${AgentRepeatMinutes}분마다 heartbeat 를 점검하고 밀린 알림을 표시한다."
# ---------------------------------------------------------------- 5. ③ DMF_Crawler_AgyUpdate
if ($SkipAgyUpdateTask) {
Write-Warn '③ DMF_Crawler_AgyUpdate 는 -SkipAgyUpdateTask 로 건너뜁니다.'
} elseif (-not (Test-Path -LiteralPath $AgyExe)) {
Write-Warn "agy.exe 를 찾을 수 없어 ③ 을 건너뜁니다: $AgyExe"
Write-Warn ' → scripts\bootstrap_agy.ps1 실행 후 이 스크립트를 다시 돌리세요.'
} else {
Write-Step '③ DMF_Crawler_AgyUpdate 구성'
$agyAt = Get-TimeOfDay -Text $AgyUpdateTime -Label 'schedule.agy_update_time'
$actionAgy = New-ScheduledTaskAction `
-Execute $AgyExe `
-Argument 'update' `
-WorkingDirectory $ProjectRoot
$trgAgy = New-ScheduledTaskTrigger -Weekly -WeeksInterval 1 `
-DaysOfWeek $AgyUpdateWeekday -At $agyAt `
-RandomDelay (New-TimeSpan -Minutes 10)
$setAgy = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-StartWhenAvailable `
-RunOnlyIfNetworkAvailable `
-ExecutionTimeLimit (New-TimeSpan -Minutes 30) `
-MultipleInstances $multipleInstances `
-Priority 7 `
-Compatibility Win8
$prcAgy = New-ScheduledTaskPrincipal -UserId $User -LogonType $LogonType -RunLevel Limited
Register-DmfTask `
-Name 'DMF_Crawler_AgyUpdate' `
-Path $TaskPath `
-Action $actionAgy `
-Trigger @($trgAgy) `
-Settings $setAgy `
-Principal $prcAgy `
-Description "agy CLI 주간 업데이트. 배치 시간대를 피해 $AgyUpdateWeekday $AgyUpdateTime 에 돈다." `
-PlainPassword $plainPassword
}
# ---------------------------------------------------------------- 6. 등록 결과 요약(검증 출력)
Write-Host ''
Write-Step '등록 결과'
Get-ScheduledTask -TaskPath $TaskPath |
Select-Object TaskName,
State,
@{ n = 'LogonType'; e = { $_.Principal.LogonType } },
@{ n = 'RunLevel'; e = { $_.Principal.RunLevel } },
@{ n = 'UserId'; e = { $_.Principal.UserId } } |
Format-Table -AutoSize
Get-ScheduledTask -TaskPath $TaskPath | ForEach-Object {
$info = $_ | Get-ScheduledTaskInfo
[pscustomobject]@{
TaskName = $_.TaskName
NextRunTime = $info.NextRunTime
LastRunTime = $info.LastRunTime
LastTaskResult = ('0x{0:X}' -f $info.LastTaskResult)
}
} | Format-Table -AutoSize
# 배터리·캐치업·중복방지 4종이 의도대로 뒤집혔는지 자동 검증(설치 직후 흔한 실수 방지).
$dailyTask = Get-ScheduledTask -TaskName 'DMF_Crawler_Daily' -TaskPath $TaskPath -ErrorAction SilentlyContinue
if ($dailyTask) {
$s = $dailyTask.Settings
$p = $dailyTask.Principal
$expect = [ordered]@{
'DisallowStartIfOnBatteries = False' = ($s.DisallowStartIfOnBatteries -eq $false)
'StopIfGoingOnBatteries = False' = ($s.StopIfGoingOnBatteries -eq $false)
"MultipleInstances = $multipleInstances" = ($s.MultipleInstances -eq $multipleInstances)
"RunLevel = Highest" = ($p.RunLevel -eq 'Highest')
'UserId != SYSTEM' = ($p.UserId -notmatch 'SYSTEM|LOCALSERVICE|NETWORKSERVICE')
}
Write-Host ''
Write-Step 'DMF_Crawler_Daily 설정 검증'
$fail = 0
foreach ($k in $expect.Keys) {
if ($expect[$k]) { Write-Host " OK $k" -ForegroundColor Green }
else { Write-Host " FAIL $k" -ForegroundColor Red; $fail++ }
}
if ($fail -gt 0) { Write-Warn "$fail 개 항목이 어긋났습니다. 이 스크립트를 다시 실행하세요." }
else { Write-Good '전 항목 통과.' }
}
# ---------------------------------------------------------------- 7. 보안 컨텍스트 프로브 (-Verify)
if ($Verify) {
Write-Host ''
Write-Step '보안 컨텍스트 프로브 시작 (DPAPI 복호화가 이 LogonType 에서 되는지 실측)'
$probeName = 'DMF_Crawler_Probe'
$probeOut = Join-Path $ProjectRoot 'state\probe.json'
if (Test-Path -LiteralPath $probeOut) { Remove-Item -LiteralPath $probeOut -Force }
# doctor --json 을 파일로 리다이렉트해야 하므로 cmd.exe 를 경유한다.
$probeCmd = '/c ""{0}" -m dmf_crawler doctor --json > "{1}" 2>&1"' -f $PythonExe, $probeOut
$actionProbe = New-ScheduledTaskAction -Execute $env:ComSpec -Argument $probeCmd -WorkingDirectory $ProjectRoot
$setProbe = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
-ExecutionTimeLimit (New-TimeSpan -Minutes 3) -MultipleInstances IgnoreNew -Compatibility Win8
$trgProbe = New-ScheduledTaskTrigger -Once -At (Get-Date).AddYears(10) # 자동 실행은 절대 안 함
$prcProbe = New-ScheduledTaskPrincipal -UserId $User -LogonType $LogonType -RunLevel Highest
Register-DmfTask -Name $probeName -Path $TaskPath -Action $actionProbe -Trigger @($trgProbe) `
-Settings $setProbe -Principal $prcProbe -Description '일회성 보안 컨텍스트 프로브(자동 삭제)' `
-PlainPassword $plainPassword
try {
Start-ScheduledTask -TaskName $probeName -TaskPath $TaskPath
$deadline = (Get-Date).AddMinutes(3)
do {
Start-Sleep -Seconds 2
$state = (Get-ScheduledTask -TaskName $probeName -TaskPath $TaskPath).State
} while ($state -eq 'Running' -and (Get-Date) -lt $deadline)
if (-not (Test-Path -LiteralPath $probeOut)) {
Write-Warn '프로브가 출력을 남기지 못했습니다. 작업이 아예 시작되지 못했을 수 있습니다.'
Write-Warn ' → Get-WinEvent -LogName "Microsoft-Windows-TaskScheduler/Operational" -MaxEvents 30 으로 확인'
} else {
$raw = Get-Content -LiteralPath $probeOut -Raw
try {
$doc = $raw | ConvertFrom-Json
$bad = @($doc.checks | Where-Object { -not $_.ok })
if ($bad.Count -eq 0) {
Write-Good "프로브 통과: LogonType=$LogonType 에서 모든 진단이 정상입니다."
} else {
Write-Warn "프로브 실패 항목 $($bad.Count) 개:"
$bad | ForEach-Object { Write-Warn (" - [{0}] {1} : {2}" -f $_.key, $_.title, $_.detail) }
if ($bad.key -contains 'api_key') {
Write-Warn ''
Write-Warn ' ★ api_key 체크가 실패했다면 DPAPI 복호화가 이 로그온 타입에서 막힌 것입니다.'
Write-Warn ' 다음 명령으로 암호 저장 방식으로 다시 등록하세요:'
Write-Warn " .\scripts\install_tasks.ps1 -LogonType Password -Verify"
}
}
} catch {
Write-Warn 'JSON 파싱 실패. 원문을 그대로 출력합니다:'
Write-Host $raw
}
}
} finally {
Unregister-ScheduledTask -TaskName $probeName -TaskPath $TaskPath -Confirm:$false -ErrorAction SilentlyContinue
Write-Step '프로브 작업 제거 완료'
}
}
Write-Host ''
Write-Good '작업 등록이 끝났습니다.'
exit 0

101
scripts/make_shortcuts.ps1 Normal file
View file

@ -0,0 +1,101 @@
#Requires -Version 5.1
<#
.SYNOPSIS
"DMF 설정.lnk" / "지금 실행.lnk" 프로젝트 루트와 바탕화면에 만든다.
.DESCRIPTION
대상은 반드시 pythonw.exe . python.exe .cmd 대상으로 하면
더블클릭할 때마다 검은 콘솔 창이 번쩍인다(PE 서브시스템 = WINDOWS_CUI).
관리자 권한은 필요하지 않다. 멱등하다 번을 다시 실행해도
기존 바로가기를 덮어쓸 중복 파일을 만들지 않는다.
.PARAMETER ProjectRoot
프로젝트 루트 경로. bootstrap.cmd 자신의 위치를 넘겨준다.
.EXAMPLE
powershell -ExecutionPolicy Bypass -File .\scripts\make_shortcuts.ps1 -ProjectRoot "D:\workspace\DMF_Crawler"
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$ProjectRoot
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Write-Step { param([string]$Message) Write-Host "[make_shortcuts] $Message" }
function Write-Warn { param([string]$Message) Write-Host "[make_shortcuts] ! $Message" -ForegroundColor Yellow }
try {
$ProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot).Path
} catch {
throw "프로젝트 루트를 찾을 수 없습니다: $ProjectRoot"
}
$pythonw = Join-Path $ProjectRoot '.venv\Scripts\pythonw.exe'
if (-not (Test-Path -LiteralPath $pythonw)) {
throw "실행 환경을 찾을 수 없습니다: $pythonw`n → bootstrap.cmd 를 먼저 실행하세요."
}
# 아이콘: 있으면 전용 아이콘, 없으면 시스템 폴백(진단 문서 아이콘)을 쓴다.
$iconFile = Join-Path $ProjectRoot 'assets\dmf.ico'
if (-not (Test-Path -LiteralPath $iconFile)) {
$iconFile = "$env:SystemRoot\System32\imageres.dll,109"
}
$desktop = [System.Environment]::GetFolderPath('Desktop')
$targets = @($ProjectRoot, $desktop) |
Where-Object { $_ -and (Test-Path -LiteralPath $_) } |
Select-Object -Unique
if ($targets.Count -eq 0) {
throw '바로가기를 놓을 수 있는 폴더가 없습니다(프로젝트 루트도, 바탕화면도 접근 불가).'
}
# 지금 실행.lnk 는 "검사 → CRITICAL 통과면 즉시 실행" 모드로 들어간다(onboard --mode inspect --run-now).
$specs = @(
@{ Name = 'DMF 설정.lnk'
Args = '-m dmf_crawler onboard --mode setup'
Desc = 'DMF 크롤러 설정 및 상태 확인' },
@{ Name = '지금 실행.lnk'
Args = '-m dmf_crawler onboard --mode inspect --run-now'
Desc = 'DMF 자료를 지금 받아 리포트를 만듭니다' }
)
$shell = New-Object -ComObject WScript.Shell
$created = 0
$failed = 0
try {
foreach ($dir in $targets) {
foreach ($s in $specs) {
$path = Join-Path $dir $s.Name
try {
$lnk = $shell.CreateShortcut($path)
$lnk.TargetPath = $pythonw
$lnk.Arguments = $s.Args
$lnk.WorkingDirectory = $ProjectRoot
$lnk.IconLocation = $iconFile
$lnk.Description = $s.Desc
$lnk.WindowStyle = 1 # SW_SHOWNORMAL — 호스트가 GUI 라 콘솔은 뜨지 않는다
$lnk.Save()
Write-Step "바로가기 생성: $path"
$created++
} catch {
Write-Warn "바로가기 생성 실패: $path ($($_.Exception.Message))"
$failed++
} finally {
if ($lnk) { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($lnk) }
}
}
}
} finally {
[void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($shell)
}
Write-Step "완료: $created 개 생성, $failed 개 실패"
# 바탕화면 접근 실패 등으로 전부 실패했을 때만 비정상 종료로 알린다.
# 부분 성공(예: 프로젝트 루트만 성공)은 실사용에 지장이 없으므로 0으로 끝낸다.
if ($created -eq 0) { exit 1 }
exit 0

View file

@ -0,0 +1,86 @@
#Requires -Version 5.1
<#
.SYNOPSIS
DMF Crawler 작업 3종(Daily / Agent / AgyUpdate) 제거한다.
.DESCRIPTION
데이터·로그·리포트는 건드리지 않는다. install_tasks.ps1 만든 4개
작업 이름(프로브 포함) 모두 대상으로 하며, 없는 것은 조용히 건너뛴다
(멱등). 실행 중인 작업은 먼저 중지한 제거한다.
.PARAMETER TaskPath
작업 스케줄러 폴더. install_tasks.ps1 동일한 기본값을 쓴다.
.PARAMETER RemoveFolder
작업 3종 제거 '\DMF_Crawler\' 폴더 자체도 지운다(비어 있을 때만
의미가 있다 다른 작업이 같은 폴더에 남아 있으면 실패해도 무해하다).
.EXAMPLE
powershell -ExecutionPolicy Bypass -File .\scripts\uninstall_tasks.ps1
.EXAMPLE
powershell -ExecutionPolicy Bypass -File .\scripts\uninstall_tasks.ps1 -RemoveFolder
#>
[CmdletBinding()]
param(
[string]$TaskPath = '\DMF_Crawler\',
[switch]$RemoveFolder
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Write-Step { param([string]$Message) Write-Host "[uninstall_tasks] $Message" }
function Write-Warn { param([string]$Message) Write-Host "[uninstall_tasks] ! $Message" -ForegroundColor Yellow }
function Write-Good { param([string]$Message) Write-Host "[uninstall_tasks] + $Message" -ForegroundColor Green }
# install_tasks.ps1 이 만드는 이름 전부(일회성 -Verify 프로브까지 포함해
# 흔적이 남지 않게 한다).
$names = @('DMF_Crawler_Daily', 'DMF_Crawler_Agent', 'DMF_Crawler_AgyUpdate', 'DMF_Crawler_Probe')
$removed = 0
$missing = 0
$failed = 0
foreach ($n in $names) {
$t = Get-ScheduledTask -TaskName $n -TaskPath $TaskPath -ErrorAction SilentlyContinue
if (-not $t) {
Write-Step "없음(건너뜀): $TaskPath$n"
$missing++
continue
}
try {
if ($t.State -eq 'Running') {
Write-Step "실행 중 → 중지: $n"
Stop-ScheduledTask -TaskName $n -TaskPath $TaskPath -ErrorAction SilentlyContinue
}
Unregister-ScheduledTask -TaskName $n -TaskPath $TaskPath -Confirm:$false
Write-Good "제거: $TaskPath$n"
$removed++
} catch {
Write-Warn "제거 실패: $TaskPath$n ($($_.Exception.Message))"
$failed++
}
}
if ($RemoveFolder) {
$svc = $null
try {
$svc = New-Object -ComObject 'Schedule.Service'
$svc.Connect()
$root = $svc.GetFolder('\')
$root.DeleteFolder($TaskPath.Trim('\'), 0)
Write-Good "폴더 제거: $TaskPath"
} catch {
Write-Warn "폴더 제거 실패(무해 — 안에 다른 작업이 남아 있을 수 있습니다): $($_.Exception.Message)"
} finally {
if ($svc) { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($svc) }
}
}
Write-Host ''
Write-Step "요약: 제거 $removed / 이미 없음 $missing / 실패 $failed"
Write-Host '완료. data\ logs\ reports\ backup\ state\ 는 그대로 남아 있습니다.'
if ($failed -gt 0) { exit 1 }
exit 0