@planuze/pack-runtime
v1.0.6
Published
Runtime de packs .plnzpack: download/cache, verify/decrypt in-memory, generator engine, orchestration, registry e uninstall.
Readme
@planuze/pack-runtime
Runtime de .plnzpack para o app desktop: baixa, verifica, decifra, materializa,
compõe e executa packs, além de manter o registry de packs carregados e o
uninstall. Pacote público (SDK de packs, ADR-0429); ancora no contrato de
@planuze/pack-format e no OS abstraído de
@planuze/platform-core.
O SDK público não resolve userData: todo caminho de cache/destino é
injetado pelo app (composition root).
Pipeline de consume (fim-a-fim)
O fluxo de produção — download → verify → decrypt → materialize — é orquestrado
por install/download-and-install.ts e
install/install-pack-to-disk.ts:
flowchart LR
A["POST /download<br/>(fetcher injetado)"] --> B["parse + fail-closed<br/>download-contract"]
B --> C["PackDownloader<br/>stream + sha256 + resume"]
C --> D["PackVerifier<br/>Ed25519 cadeia"]
D --> E["PackDecryptor<br/>XChaCha20 + untar in-memory"]
E --> F["materialize → targetDir<br/>+ docs em claro"]downloadAndInstallPack(input)chama ofetchDownloadinjetado (no app é umPOST /v1/marketplace/:packId/downloadautenticado ao api-gateway), valida a resposta contrapackDownloadResponseSchema(install/download-contract.ts) e a mapeia paraInstallPackInputfail-closed: semcontentKeyHex(escrow-unwrap do registry), semfileHash/fileBytes(integridade) ou sempublisherPublicKeyPem(chave para a cadeia), aborta em vez de instalar parcialmente (ADR-0393/0439/0440).installPackToDisk(input)executadownload → verify → decrypt → materialize, reporta progresso por etapa viaonProgress(ADR-0489) e devolve oPackManifestinstalado. Single-pack por design (o NEP é template standalone;stack.requiressão capabilities de ambiente, não um grafo de download).
Download
PackDownloader (download/downloader.ts) é um
DisposableBase que faz streaming HTTP com:
- Verificação de integridade — hash SHA-256 e contagem de bytes computados no
stream; divergência de
expectedHash/expectedBytesdescarta o.partiale falha (validationFormat). - Resume —
.partialno cache; enviaRange: bytes=N-e só retoma com206 Partial Contentcujocontent-rangecasa (senão trunca e rebaixa a200, escrevendo do zero — anexar corromperia hash). - Rename atômico — grava em
<file>.partial, valida, e só entãorenamepara o nome final<segment>-<version>.plnzpack. - Path-safety do nome — o packId vai no filename como segmento on-disk
(
acme~widget, ADR-0367):packIdToSegmentcolapsa o/scoped em~; o/nunca toca o filesystem. - Cancelamento e leak-safety —
AbortControllerpor download in-flight (odispose()aborta todos), timeout de headers, etry/finallyque libera o reader lock + destrói o write stream em erro. - Progresso throttled —
onBytesreporta a cada ~256 KiB (ADR-0491), com a posição retomada reportada imediatamente. purgeVersions(packId, keep)remove versões antigas do cache por mtime.
Verify
PackVerifier (decrypt/verifier.ts) verifica sobre um
PackArchiveReader (do pack-format) em dois modos:
- direto (
KeyObject) — o pack foi assinado por essa chave. Usado pelopack inspect(inspeção local pré-publicação, chave ainda não root-assinada). - cadeia (
PackChainConfig, ADR-0440) — modo do consume:pack ← pubkey do publisherEpubkey do publisher ← root(root = âncora embarcada no app).
verifyAll(reader) faz, em ordem fail-closed: pré-check de que
manifest.publicKeyFingerprint bate com a chave de assinatura (defesa contra key
confusion + downgrade, §4.8); no modo cadeia, manifest.publisherFingerprint bate
e a pubkey está root-assinada (signedByRootSignature !== null); manifest.sig
sobre manifest.json; content.sig sobre pack.lock; e verifyPackLock (hashes
de cada blob).
Decrypt
PackDecryptor (decrypt/decryptor.ts) decifra os
blobs in-memory: lê content/{base,generator,agent}.tar.zst, desserializa o
envelope XChaCha20-Poly1305 (com AAD ${packId}@${version}), decifra com a content
key e extrai o tar em memória (decrypt/tar.ts via
tar-stream) para um Map<path, Buffer>. A extração tem os mesmos caps anti-DoS
do reader (≤20 000 entradas, ≤32 MiB/entrada, ≤256 MiB total) e rejeita paths
inseguros. agent é opcional (ausência = missing_archive_entry tratado como
undefined). Um contentDecoder opcional permite injetar descompressão zstd; o
default é identidade.
O resultado é um DecryptedPackHandle (decrypt/handle.ts):
guarda manifest + contents (base/generator/agent?) e, em dev mode, o
sourcePath. É DisposableBase — o dispose() zera os buffers de conteúdo
(buffer.fill(0)) antes de limpar os Maps (§4.9).
installPackToDisk materializa o handle em targetDir
(handle/materializeDecryptedPack.ts),
depois copia os docs/** em claro do archive (best-effort — a doc é acessória e
nenhuma falha aqui invalida um install cujo código já materializou; ADR-0575), e
descarta o handle liberando a memória.
handleFromFilesystem(packPath, packId) (handle/handleFromFilesystem.ts)
constrói o mesmo handle direto de um diretório de pack já decifrado (dev mode
no monorepo, ou prod pós-install) — bypass do pipeline criptográfico, com
path-safety (resolveInside) no walk recursivo.
Engine (generator em subprocesso)
GeneratorEngine (engine/generator-engine.ts)
executa o generator do pack e expõe um Observable<GeneratorEvent> (RxJS):
- Staging isolado —
prepareGeneratorcria um tmpdir, materializagenerator/base/+manifest.json+ umpackage.jsonmínimo (type: module, para o loader tsx), e cria um symlinknode_modules→sourcePath/node_modules(dev mode) resolvendo as workspace deps. Paths reservados (node_modules,package.json) não podem vir do pack.
- Runner — spawn via
platformProcessRunner(engine/process-runner.ts, sobrerunProcessdo platform-core): prefere.bin/tsx(com fallback.cmdno Windows), senãonode. Env allowlist (generatorBaseEnvironment) + vars de contexto injetadas:PLANUZE_PACK_DIR,PLANUZE_PROJECT_PATH,PLANUZE_PACKS_DIR(localizar packs-filhos no stepmodule-packs),PLANUZE_MODULE_PATH/_ID/_ACTIVE_MODULES/_PROPS(module-packs), e o par do approval workflow (PLANUZE_APPROVAL=ndjson+PLANUZE_AI_KEY/_PROVIDER). - Protocolo NDJSON — o
init(projectPath, project, steps/models a rodar, firstRun) vai pelo stdin; o subprocess emite eventos linha-a-linha no stdout, cada um validado porgeneratorEventSchema(engine/events.ts). Exit code ≠ 0 virasubscriber.error. Ostdinfica aberto quando o caller registraonInput(round-trip do approval), ou é fechado após o init em runners não-interativos (senão o readline do generator segura o event loop; ADR-0265). - Lifecycle —
IDisposable: o teardown encerra o stdin, mata o subprocess e remove o tmpdir.
resolveModuleTargetPath(...) e resolveInstallTarget centralizam a regra de
"onde o pack escreve" a partir do kind.
Modo de criação nas escritas do gerador
As escritas de generator-tools (writeAtomic, writeIfChanged e
writeWithApproval) aceitam mode?: number. Um gerador pode solicitar, por
exemplo, mode: 0o600: o temporário já nasce com esse modo, filtrado pelo umask,
antes de receber conteúdo e substituir o destino. Sem a opção, o padrão anterior
é mantido. Conteúdo igual e decisão de pular continuam sem escrita ou chmod;
a opção não migra permissões de arquivos existentes automaticamente. Bits POSIX
não representam uma garantia de ACL no Windows. Aprovação, contenção de paths
e política de conteúdo permanecem responsabilidades das respectivas APIs.
Contrato de eventos
generatorEventSchema (discriminated union por type, schemaVersion: 2):
step.started / step.log / step.completed / step.failed / run.finished, e
os eventos do approval workflow step.prompt_requested / step.prompt_response /
file.diff_proposed / file.write_decision. O tipo é exportado também via
@planuze/pack-runtime/generator-tools para o generator de cada pack validar a
própria saída.
Orchestrator (progresso via RxJS)
StepOrchestrator (orchestrator/step-orchestrator.ts)
consome o Observable<GeneratorEvent> e reduz a um OrchestratorState observável
(steps com status pending/running/completed/failed, logs, progresso
current/total, currentStepId, timing). Expõe state$ como
BehaviorSubject.asObservable() — caller externo não consegue empurrar
.next() e quebrar o invariante (anti-pattern de segurança do CLAUDE.md);
currentState é o snapshot síncrono para reidratação de UI. IDisposable
completa o subject e cancela a subscription. Um OrchestratorMetrics opcional
recebe recordStep/recordRun. Erros de step são normalizados para texto legível
(kind: messageKey) — nunca String(appError) cru.
Registry (packs carregados)
PackRegistry (registry/pack-registry.ts) é o
cache in-memory de DecryptedPackHandles (DisposableBase):
load(packId, version, contentKey, credentials)— resolve o.plnzpackdo cache (ioNotFoundse ausente), monta umPackVerifierde cadeia com asPackLoadCredentialsdo/download, verifica, decifra e memoiza o handle. Deduplica requisições concorrentes (mapaloadingde promises in-flight) e invalida handles descartados.get/unload(packId, version)/listInstalled()— olistInstalledvarre o cache porparsePackFileName(segmento on-disk → packId on-wire).- O
dispose()descarta todos os handles (zerando buffers) e o downloader próprio.
Além do registry criptográfico, discover-monorepo-packs / installed-packs-schema
/ read-installed-packs / write-installed-packs gerenciam o registro persistente
de packs instalados no disco.
Uninstall (idempotente)
PackUninstaller (uninstall/uninstaller.ts)
descarrega do registry e remove os arquivos do cache. uninstall(packId,
version?) — sem version, remove todas as versões. Idempotente: pula
arquivos já ausentes (existsSync) e conta só o que removeu; emite events$
(observable read-only) por remoção. DisposableBase.
Compose (template + extensions)
composeManifests(input) (compose/composer.ts) é
data-only (não toca disco nem processo): combina um template com suas
extensions ativas em um manifest efetivo. Valida kinds e compatibilidade
(appliesTo.packId bate; satisfiesRange da versão), faz merge aditivo de
declarations detectando conflito de id cross-source, incorpora declarations/steps
dos modules ativos por extension, e faz topological sort dos steps (Kahn, com
dependsOn resolvido same-source→global e ordenação estável) detectando ciclos.
Devolve Result<ComposedManifest, ComposeError> com erros discriminados
(kind_mismatch, version_incompatible, duplicate_declaration,
circular_dependency, unknown_module, …).
Approval workflow (diff antes de sobrescrever)
Quando o generator roda sob o app Electron (subprocess non-TTY), o pack não
sobrescreve arquivos do usuário em silêncio. O engine só liga o canal
(PLANUZE_APPROVAL=ndjson) quando o app cabeia onInput (há quem responda); sem
isso, CLI/headless caem no merge determinístico automático.
writeWithApproval(opts)(generator-tools/write-with-approval.ts) cobre steps diretos: arquivo novo → escreve; arquivo existente e igual → no-op; existente e diferente + canal ligado → reinjeta os blocos//#ignoredo disco (merge determinístico, comlowConfidencepara blocos reposicionados sem âncora), emitefile.diff_proposede bloqueia atéfile.write_decision. Ações:overwrite(aplica o merge mostrado),skip,ai-merge(conflitos//#ignorevia LLM, só comPLANUZE_AI_KEY),approve-all/reject-all(batch por run). Degrada parawriteIfChangedse o diff falhar ou o app fechar o stdin.- O caminho do Plop tem seu próprio write action em
generator-tools/plop-write/(write-action,diff,ai-merge,prompt) sobre o mesmo protocolo. ndjson-approval(generator-tools/plop-write/ndjson-approval.ts) é o cliente NDJSON no lado do subprocess: um readline persistente único multiplexado pordecisionId/promptId(criar um readline por prompt faria a 2ª aprovação nunca receber oline), com teardown auto-disparado nobeforeExit(omain()do generator é código do pack, sem finally nosso).requestPromptsuportaconfirm/input/list/checkbox/password.
Generator tools (subpath @planuze/pack-runtime/generator-tools)
Toolkit importado pelos steps do generator dentro do subprocesso: emit-event,
file-io, format (prettier), glob-files (fast-glob), render-hbs +
hbs-helpers (handlebars), ignore-utils (blocos //#ignore),
collect-project-schema, write-with-approval + plop-write/*, e
resolveInside/resolveInsideRealpath. Este subpath é toolkit-puro: o gate
check:no-domain-imports proíbe hardcodar nomes de domínio ('show', 'String',
'cpf', 'prisma') aqui (§4.18, ADR-0241).
API pública (subpath exports)
| Import | Conteúdo |
|---|---|
| @planuze/pack-runtime | download / decrypt / engine / install / orchestrator / registry / uninstall / compose |
| @planuze/pack-runtime/generator-tools | toolkit dos steps do generator |
| @planuze/pack-runtime/registry | registry + descoberta de packs instalados |
| @planuze/pack-runtime/handle | handleFromFilesystem / materialize |
| @planuze/pack-runtime/scaffold | copy/rename/render de scaffold |
Boundaries e build
- Gate
check:cruiser(regrapack-runtime-*): só depende de@planuze/core-kit,@planuze/platform-coree@planuze/pack-format(+rxjs,tar-stream,fast-glob,handlebars,diff,prettier,@inquirer/prompts). - Toda operação com falha esperada devolve
Result<_, AppError | PackFormatError>(§4.6); classes com recursos externos sãoIDisposable(§4.5).
pnpm --filter @planuze/pack-runtime build # tsc modular → dist/
pnpm --filter @planuze/pack-runtime test # vitest run
pnpm --filter @planuze/pack-runtime typecheck # tsc --noEmit