From 933fffad92c385db86d45c11e6a14a7062684ee6 Mon Sep 17 00:00:00 2001 From: dantesito Date: Thu, 20 Aug 2026 14:50:15 -0300 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20dcw=20v2=20=E2=80=94=20rehauled=20C?= =?UTF-8?q?LI,=20hardened=20by=20default,=20plus=20security=20audit=20(#30?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: replace legacy core with rehauled CLI Deletes the legacy CommonJS devcontainer.json generator and moves the oclif + ink rehaul into packages/core. Drops the standalone-workspace leftovers (nested lockfile, nested pnpm-workspace.yaml, self-referential link: dependency) and folds allowBuilds into the root workspace file. * feat: publish rehauled CLI as @theredguild/devcontainer-wizard@2.0.0 Claims the established npm identity instead of debuting under the unpublished @theredguild/dcw name. Installs both a dcw and a devcontainer-wizard binary; dcw stays the advertised command. Adds a ./bin/run.js export subpath so the unscoped wrapper package can resolve the CLI entry, plus an oclif manifest prepack step. * fix(wrapper): resolve the CLI entry subpath, bump to 2.0.0 The scoped package's '.' export now points at the library index, so require.resolve of the bare name spawned a module that does nothing. Resolve @theredguild/devcontainer-wizard/bin/run.js instead. Also adds a dcw bin, drops the unused @oclif/core dependency, and replaces the stale README copied from the legacy core package. * docs: rewrite for v2 and repoint the skill at the scoped name Root and core READMEs now document the oclif/ink CLI rather than the v1 devcontainer.json wizard, and state that both dcw and devcontainer-wizard binaries are installed. SKILL.md references @theredguild/devcontainer-wizard. Drops the stale REPORT.md. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017LP6DbMB3irfkRdh31tV8e * fix: apply code review fix wave for dcw v2 republish Addresses the 8 findings from the final whole-branch review: - README.md / packages/core/README.md: warn about the npm EEXIST global install collision between the two published bin names, document `dcw attach` (v1 -> v2 editor workflow) in Quick start and Upgrading from v1, and note that Apple Containers does not enforce network isolation (airgapped profiles stay networked without --strict). - packages/core/skill/SKILL.md: add `dcw attach` to the command table and a full usage subsection, and add the missing --[no-]ssh and --force create flags, so the "full command surface, flag vocabulary" claim in the frontmatter holds. - packages/wrapper/README.md: add a breaking-change notice for the v1 audience on the unscoped npm name, with the correct v1 pin (devcontainer-wizard@1). - Add MIT LICENSE at the repo root and copy it into packages/core and packages/wrapper (workspace subpackages don't inherit the root file), wired into both packages' `files` array. - pnpm-workspace.yaml: replace the inert `allowBuilds` key with the real `ignoredBuiltDependencies` key so pnpm actually skips esbuild's/@pnpm's build scripts instead of warning about them. - Remove the stale, unshipped packages/wrapper/pnpm-lock.yaml (v1-era, references packages no longer used). - packages/wrapper/bin.js: forward SIGINT/SIGTERM/SIGHUP to the spawned child so killing the wrapper directly no longer orphans the CLI process. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017LP6DbMB3irfkRdh31tV8e * docs(skill): correct three inaccurate claims in the shipped agent skill SKILL.md ships inside the npm tarball and is read by AI agents to drive the CLI, so a wrong claim there misleads automation rather than a human who can check --help. - The "every command" qualifier on global flags was untrue: dcw skill takes none of them, and exec/shell/logs/agent set enableJsonFlag = false, so --json yields no payload there and their stdout must not be parsed as JSON. - attach --workspace was undocumented; it sets the host directory mounted at /workspace when attach has to start a stopped container. - rm --purge silently requires --yes, throwing E_CONFIRM with exit 2. Verified against src/base-command.ts, src/commands/{skill,exec,shell,logs, agent,attach,rm}.ts and src/errors.ts. * fix(engine): report Apple Containers and Docker capabilities from reality Two capability maps described engines that do not exist, in opposite directions, and both defeated `--strict`. Apple Containers declared `capDrop` unsupported ("no Linux capability management; isolation is provided by the per-container VM"), so the translator dropped it. Verified against `container` CLI 1.0.0, it is exposed and genuinely enforced: `--cap-drop ALL` takes CapEff from 00000000a80425fb to 0000000000000000. Every profile on that engine ran with the full default capability set, NET_RAW included. `--read-only` is enforced there too, but stays unsupported on purpose: `readonly-os` always pairs it with ten tmpfs mounts, five carrying uid=1000,gid=1000, and Apple's `--tmpfs` takes the whole argument as the mount path, so those options cannot be expressed. Emitting bare paths would mount root-owned empty filesystems over /home/vscode/.local and .ssh, hiding baked tools and breaking `dcw attach`. Dropping the pair loudly beats applying half of it silently. Docker declared AppArmor supported on every host. On macOS it runs a Linux VM with no AppArmor LSM: the flag is accepted, `docker inspect` returns an empty AppArmorProfile, and the container has no /proc/self/attr/current. Availability now comes from the daemon via `docker info` SecurityOptions rather than the client's platform, so a macOS client driving a remote Linux daemon over DOCKER_HOST is judged correctly in both directions, and an unprobeable daemon fails closed. This makes `--strict` fail on macOS for all four built-in profiles, which is the honest result. Podman and Lima already modelled this. Also fixes `container list --format json` parsing: the schema nests everything under `configuration`/`status`, so reading Docker's flat keys produced name:"" and status:"[object Object]", and the label filter was accepted but ignored (the CLI has no --filter). `dcw ls` called running containers absent, the presence guard in stop/rm matched the unrelated buildkit container, and environments became unremovable. A malformed row is now skipped rather than throwing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy * fix(hardening): make --strict fail closed and show applied hardening in --json dcw could request a control, watch the engine quietly not apply it, and report success. Three separate paths let that through. `--json` omitted it. `dcw up --json` reported dropped controls, but `create --up --json` and `attach --json` did not: warnings went to this.warn(), which JSON consumers never see. An agent running `create --profile airgapped --up --json` against an engine that cannot air-gap got `started: true` and no field anywhere in the envelope indicating the container was networked. All three now return the same HardeningReport: appliedFlags, warnings, dropped, unenforced. `--strict` was skipped by every command that enters a running container. It promises to fail if requested hardening cannot be honored; up/create enforced that when starting one, but exec, shell and agent ignored the flag entirely. A shared assertStrictContainer() preflight now runs before entering. It blocks on unenforced controls as well as dropped ones -- AppArmor on a daemon with no LSM is not "dropped" (the flag is on the command line, it just does nothing), so checking droppedHardening alone walked straight past it. The manifest records unenforcedHardening to make that visible. `dcw agent` inferred the air-gap from silence. It read spec.hardening -- what was requested -- so on an engine that cannot honor --network=none it told the user the agent had no network, then forwarded ANTHROPIC_API_KEY into a container that was online, alongside the untrusted code under audit. It now requires positive evidence: --network=none must actually appear in the flags the container was launched with. Missing evidence returns 'unknown' and is treated as unsafe, because absence of evidence is not evidence of enforcement. Two ordering fixes so refusals cost nothing: strict is evaluated before the image build (the verdict depends only on plan and capabilities, both known upfront, so a doomed run no longer builds for minutes first), and `agent --strict --install` decides before npm install mutates the container. Finally, oclif parse errors under --json bypassed the envelope contract: `dcw ls --json --nope` serialized the whole CLIError -- ~120 kB of resolved config, home directory, shell and plugin list -- to stdout with no code, no message, and exit 1 instead of the documented 2. Now a 113-byte E_USAGE envelope with the parse error's own exit code. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy * fix(attach): bind published SSH to loopback and refuse before mutating `dcw attach --port` emitted a bare `-p 2222:2222`, which Docker publishes on 0.0.0.0 -- an SSH server on a container full of untrusted contract code, reachable from the LAN. Confirmed against a live container: HostIp "" and a listener on *:39222 over both v4 and v6. findFreePort() already probes 127.0.0.1, so loopback was plainly the intent; it is now explicit. Three ordering bugs let attach do irreversible work before reaching a refusal it could have made up front: - `--port` on an air-gapped environment is correctly rejected, but the guard lived in upEnvironment, downstream of a force-remove. Running it killed the live container -- losing an ephemeral tmpfs /workspace -- and only then reported the request was impossible. - `--strict` was checked after provisioning SSH keys, writing ~/.ssh/config and potentially launching an editor. - restarting for `--port` force-removed the running container before the fresh-translation strict check could object. All three now decide before touching anything. A container being reused is judged by what was recorded when it actually started, not by re-running translate() against today's capability map. A container started before the capability corrections in 0369d20 -- with drop-cap genuinely dropped and no --cap-drop on its command line -- would otherwise have passed `attach --strict` under rules it was never launched under, and reported nothing dropped. `--folder` must now be absolute: Zed's remote target is a URL, so `--folder work` silently produced ssh://dcw-demowork. Validated before any environment lookup, since it needs none. Also extracts isOnPath(). Both editor detection and ProxyCommand resolution spawned `command -v`, but `command` is a shell builtin -- macOS ships /usr/bin/command, most Linux distros do not -- so on Linux both silently took their fallback path. resolveDcwInvocation() had no `which` fallback at all, so it always wrote an absolute node+entry ProxyCommand there instead of the stable `dcw ssh-proxy`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy * fix(state): keep dcw state private, and stop guessing about containers Manifests inherited a 022 umask -- 0644 files, 0755 directories. They record appliedFlags, which embed the absolute workspace path and the repo URL, so any other local account could read where you work and on what. Now 0600/0700, with an explicit chmod before the rename so rewriting an already-loose manifest tightens it, and a chmod on the directory too: mkdir's mode applies only to directories it creates, so one left 0755 by an earlier version would have stayed readable forever. loadManifest() now refuses a manifest whose inner name does not match its filename. Commands resolve an environment by filename but then act on the inner name -- image tag, container name, state dir -- so `dcw build outer` would build, tag and persist state for `inner`, silently clobbering another environment's namespace. listManifests() already skipped these; loadManifest() handed them back. `rm --purge` no longer needs a working engine. Purging is mostly local state, but engine resolution ran first and unconditionally, so with Docker uninstalled or its daemon down the command failed with E_NO_ENGINE and the environment could never be deleted. Resolution is best-effort under --purge; non-purge still fails as before. `dcw ls` reconciled every environment against a single auto-detected engine, so one created on another engine read as absent while running. Engine failures were also swallowed into a definite 'absent' -- its own doc comment listed an 'unknown' status the code never produced. It now reconciles each environment against its own engine and says 'unknown' when the engine cannot answer, rather than asserting the container is gone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy * fix(spec): close git URL credential and option-injection gaps The scheme-URL rule forbade `@` outright to block https://user:token@host. That also blocked ssh://git@github.com/o/r.git -- the exact form the validation error recommends for private repos -- while the identical scp-style git@github.com:o/r.git was accepted, on the stated grounds that `user@` is a login and not a secret. The two forms are the same remote written two ways, so they are now treated alike: a bare `user@` is allowed on ssh:// and git://, while user:password@ and all http(s) userinfo stay rejected. Percent-encoding walked straight through that check. `ssh://user%3Apass%40host/repo` contains no literal `:` or `@` in its userinfo, so it satisfied the regex; git then percent-decodes it and connects as user:pass@host. The credentials the rule exists to block were persisted to the manifest and the generated Containerfile anyway. `%` is now excluded from every URL branch -- no remote dcw supports needs it. assertCloneSafe() rejected shell metacharacters but not a leading dash, so `--upload-pack=` -- a build-time RCE primitive -- passed the check that exists precisely to catch it. Not reachable through the CLI, since zod rejects it at the boundary first, but this function is the defence-in-depth layer before the value is spliced into an unquoted `RUN git clone` line, and it now covers the case independently. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy * feat(hardening)!: default to the development profile, not to nothing With no --profile, the hardening set was empty: no capability drops, no no-new-privileges, no secure tmpfs. `--strict` passed vacuously, because nothing had been requested that could fail. The wizard reinforced it -- "None (no extra hardening)" was listed first, and the selector highlights the first choice -- so accepting every default in a tool whose purpose is isolating untrusted contract code produced an environment with no isolation at all. Absence of a choice now means the default posture rather than no hardening: `development` (secure tmpfs, no-new-privs, apparmor, secure DNS). Naming --harden keys is itself a deliberate choice, so the default applies only when neither --profile nor --harden was given -- it is never merged on top of explicit keys. `--profile none` is the explicit opt-out, and unknown-profile errors now point at it. In the wizard, profiles lead and "None" moves to the bottom, so opting out is a deliberate act rather than the path of least resistance. BREAKING CHANGE: `dcw create` with neither --profile nor --harden now produces a `development`-hardened environment instead of an unhardened one. Scripts that relied on the empty set must pass --profile none. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy * chore: make `pnpm test` work from the repo root The root manifest had no scripts block, so `pnpm test` exited 1 with no output at all -- the failure mode where nothing appears to be wrong. Adds build/test/test:watch/test:e2e/clean, each delegating to the core package. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy * docs: document hardened-by-default, --profile none, and macOS AppArmor Catches the docs up to two behavior changes they did not describe. dcw create with neither --profile nor --harden now applies the development profile rather than creating an unhardened environment; --profile none is the explicit opt-out. Both READMEs and SKILL.md previously implied the opposite, and SKILL.md hedged the development profile as "default-ish" while listing no none entry at all. For a security tool, docs that understate the default are the wrong direction to be wrong in. AppArmor is not enforced on macOS: Docker Desktop and OrbStack run containers in a Linux VM whose daemon reports no AppArmor support, now probed via docker info SecurityOptions instead of assumed. The engine table's Docker row claimed "Full Linux MAC" unconditionally on all platforms, which was wrong on macOS. SKILL.md now also states that --strict fails closed there, so agents do not report AppArmor protection that is not present. Verified against src/domain/profiles.ts (DEFAULT_PROFILE, NO_PROFILE), src/commands/create.ts, src/engine/drivers/docker.ts and src/wizard/App.tsx. Suite green at 247 passed / 1 skipped. * docs: correct the hardening-degradation contract and document the JSON surface c5f47d0 said AppArmor is "dropped with a warning" on macOS. It is not dropped: dockerCaps() marks it caveated with enforced:false, and translate() routes that into `unenforced` -- only `unsupported` reaches `dropped`. An agent following that sentence would check `dropped`, find [], and report the environment as fine. Verified live: a hardened env on this host returns dropped:[] and unenforced:["apparmor"]. The same defect ran wider than that one sentence. Both READMEs and SKILL.md described degradation as a single bucket -- "if the engine can't honor an option it is dropped with a warning" -- which is incomplete for every caveated control, not just AppArmor. All three now name both buckets: dropped (never applied) and unenforced (applied, but the engine may not enforce it), either of which fails --strict. Documents the JSON surface agents actually script against, none of which was written down: - exit-code table (0-9) with the machine `code` for each, replacing the bare claim that "exit codes are deterministic" - the --json error envelope, now uniform for usage errors too, and the rule that warnings go to stderr so stdout stays parseable - `unenforced` alongside appliedFlags/warnings/dropped on `up --json`, and the `hardening` object on `create --up --json` and `attach --json` - `dcw ls` status vocabulary, including the new `unknown` (engine unreachable) which must not be read as `absent`, and the fact that `ls` stays non-fatal where other commands exit 5 - --strict now also refusing to enter an already-running container that was started with dropped or unenforced hardening - git URL rules: bare ssh://user@host is fine, embedded credentials and percent-encoding are rejected, and why (the URL is persisted) - `dcw agent` distinguishing an enforced air-gap from a dropped or unknown one before forwarding provider credentials - published SSH ports binding 127.0.0.1 only Corrects the Apple Containers engine row, which still claimed it "drops Linux cap ... hardening". It applies --cap-drop; what it drops is read-only rootfs, tmpfs options, no-new-privileges, AppArmor and seccomp. Also corrects the wrapper README, the npm page the unscoped v1 install base lands on: installing both packages globally does not leave them "competing for the same symlinks", npm aborts with EEXIST and installs nothing. Adds the uninstall-first path. Every documented exit code was re-verified against the built CLI. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy * docs: drop the packages/desktop workspace entry README listed `packages/desktop` as a workspace member, but it was never tracked -- 0 files in git, no commit on this branch touched it -- so a fresh clone never had it. The "How to contribute" section described a directory contributors could not see. It carried no package.json either, so pnpm never treated it as a workspace project; `pnpm install --frozen-lockfile` still reports the same three projects and the lockfile is unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FRxTTemH6d3bUGvLKwL5yy --------- Co-authored-by: Claude Opus 5 --- LICENSE | 21 + README.md | 303 +- package.json | 7 + packages/core/.gitignore | 115 +- packages/core/LICENSE | 21 + packages/core/README.md | 294 +- packages/core/bin/dev.cmd | 3 - packages/core/bin/dev.js | 13 +- packages/core/bin/run.cmd | 3 - packages/core/bin/run.js | 8 +- packages/core/bin/skill-flag.js | 10 + packages/core/package.json | 92 +- packages/core/pnpm-lock.yaml | 3151 ----------------- packages/core/skill/SKILL.md | 349 ++ packages/core/src/base-command.ts | 79 + packages/core/src/cli/context.ts | 133 + packages/core/src/commands/agent.ts | 220 ++ packages/core/src/commands/attach.ts | 271 ++ packages/core/src/commands/build.ts | 78 + packages/core/src/commands/create.ts | 188 + packages/core/src/commands/engines.ts | 76 + packages/core/src/commands/exec.ts | 41 + packages/core/src/commands/logs.ts | 39 + packages/core/src/commands/ls.ts | 86 + packages/core/src/commands/rm.ts | 97 + packages/core/src/commands/schema.ts | 16 + packages/core/src/commands/shell.ts | 26 + packages/core/src/commands/skill.ts | 28 + packages/core/src/commands/ssh-proxy.ts | 43 + packages/core/src/commands/stop.ts | 57 + packages/core/src/commands/up.ts | 106 + packages/core/src/containerfile/base.ts | 132 + packages/core/src/containerfile/generate.ts | 148 + packages/core/src/containerfile/guard.ts | 67 + packages/core/src/containerfile/shims.ts | 38 + packages/core/src/core/build-pipeline.ts | 124 + .../src/core/devcontainer/devcontainerExec.ts | 61 - .../src/core/devcontainer/devcontainerUp.ts | 111 - packages/core/src/core/devcontainer/index.ts | 4 - .../src/core/devcontainer/prebuiltList.ts | 117 - .../core/devcontainer/resolvePrebuiltPath.ts | 70 - packages/core/src/core/plan.ts | 37 + .../core/src/core/scripts/generate_dev_env.ts | 464 --- packages/core/src/core/ssh/attach.ts | 49 + packages/core/src/core/ssh/editors.ts | 87 + packages/core/src/core/ssh/keys.ts | 72 + packages/core/src/core/ssh/provision.ts | 149 + packages/core/src/core/ssh/ssh-config.ts | 108 + packages/core/src/core/up-pipeline.ts | 109 + .../core/src/core/wizard/coreLanguages.ts | 18 - .../core/src/core/wizard/devcontainerName.ts | 32 - packages/core/src/core/wizard/frameworks.ts | 17 - .../core/src/core/wizard/fuzzingTesting.ts | 19 - packages/core/src/core/wizard/gitClone.ts | 73 - packages/core/src/core/wizard/index.ts | 12 - packages/core/src/core/wizard/languages.ts | 16 - packages/core/src/core/wizard/savePath.ts | 36 - .../core/src/core/wizard/securityProfiles.ts | 197 -- .../core/src/core/wizard/securityTooling.ts | 20 - .../core/src/core/wizard/systemHardening.ts | 125 - .../core/src/core/wizard/vscodeExtensions.ts | 59 - packages/core/src/core/wizard/wizard.ts | 137 - packages/core/src/domain/catalog.ts | 84 + .../core/src/domain/dependency-resolver.ts | 98 + packages/core/src/domain/hardening.ts | 53 + .../install-commands.ts} | 54 +- packages/core/src/domain/normalize.ts | 53 + packages/core/src/domain/profiles.ts | 119 + .../src/engine/drivers/apple-container.ts | 137 + .../core/src/engine/drivers/capabilities.ts | 75 + .../core/src/engine/drivers/cli-driver.ts | 231 ++ packages/core/src/engine/drivers/docker.ts | 28 + packages/core/src/engine/drivers/lima.ts | 32 + packages/core/src/engine/drivers/orbstack.ts | 47 + packages/core/src/engine/drivers/podman.ts | 24 + packages/core/src/engine/exec.ts | 62 + packages/core/src/engine/host.ts | 105 + packages/core/src/engine/registry.ts | 32 + packages/core/src/engine/resolver.ts | 125 + packages/core/src/engine/types.ts | 128 + packages/core/src/errors.ts | 75 + packages/core/src/hardening/effects.ts | 167 + packages/core/src/hardening/flag-emitters.ts | 39 + packages/core/src/hardening/translator.ts | 135 + packages/core/src/index.ts | 173 +- packages/core/src/skill.ts | 22 + packages/core/src/spec/env-spec.ts | 76 + packages/core/src/spec/flags-to-spec.ts | 114 + packages/core/src/spec/schema-doc.ts | 40 + packages/core/src/state/manifest.ts | 83 + packages/core/src/state/paths.ts | 37 + packages/core/src/state/store.ts | 126 + packages/core/src/types.d.ts | 38 - .../components/checkboxWithTopDescription.ts | 156 - .../src/ui/components/confirmWithFooter.ts | 71 - packages/core/src/ui/components/index.ts | 7 - .../src/ui/components/inputWithSymbols.ts | 183 - .../ui/components/selectWithTopDescription.ts | 142 - packages/core/src/ui/styling/colors.ts | 31 - packages/core/src/ui/styling/confirm.ts | 28 - packages/core/src/ui/styling/index.ts | 4 - packages/core/src/ui/styling/symbols.ts | 24 - packages/core/src/ui/styling/ui.ts | 75 - packages/core/src/util/slug.ts | 31 + packages/core/src/util/which.ts | 16 + packages/core/src/utils/openIn.ts | 27 - packages/core/src/utils/shouldRun.ts | 41 - packages/core/src/utils/versionCheck.ts | 266 -- packages/core/src/wizard/App.tsx | 311 ++ .../core/src/wizard/components/Banner.tsx | 14 + .../src/wizard/components/MultiSelect.tsx | 64 + .../core/src/wizard/components/Select.tsx | 66 + .../core/src/wizard/components/TextInput.tsx | 40 + packages/core/src/wizard/hooks/useStepNav.ts | 19 + packages/core/src/wizard/run.tsx | 41 + packages/core/test/e2e/smoke.test.ts | 48 + packages/core/test/engine/fake-driver.ts | 92 + packages/core/test/engine/host.test.ts | 49 + packages/core/test/engine/resolver.test.ts | 82 + .../__snapshots__/containerfile.test.ts.snap | 110 + packages/core/test/unit/agent-airgap.test.ts | 55 + packages/core/test/unit/agent-env.test.ts | 28 + .../test/unit/apple-container-caps.test.ts | 54 + .../core/test/unit/apple-list-parse.test.ts | 74 + packages/core/test/unit/attach-ssh.test.ts | 168 + packages/core/test/unit/capabilities.test.ts | 75 + packages/core/test/unit/containerfile.test.ts | 129 + .../test/unit/dependency-resolver.test.ts | 80 + packages/core/test/unit/env-name.test.ts | 37 + .../test/unit/exec-env-resolution.test.ts | 60 + packages/core/test/unit/exec-secrets.test.ts | 26 + packages/core/test/unit/flags-to-spec.test.ts | 201 ++ packages/core/test/unit/guard.test.ts | 96 + .../core/test/unit/hardening-report.test.ts | 38 + .../core/test/unit/install-commands.test.ts | 20 + .../test/unit/json-error-envelope.test.ts | 59 + packages/core/test/unit/json-flag.test.ts | 24 + packages/core/test/unit/logs-tail.test.ts | 11 + packages/core/test/unit/ls-reconcile.test.ts | 66 + .../core/test/unit/manifest-migration.test.ts | 86 + packages/core/test/unit/pipelines.test.ts | 190 + packages/core/test/unit/profiles.test.ts | 60 + packages/core/test/unit/rm-no-engine.test.ts | 60 + packages/core/test/unit/schema-doc.test.ts | 25 + packages/core/test/unit/skill.test.ts | 45 + packages/core/test/unit/ssh-config.test.ts | 96 + packages/core/test/unit/ssh-keys.test.ts | 33 + .../test/unit/ssh-proxy-invocation.test.ts | 15 + .../core/test/unit/state-permissions.test.ts | 83 + .../core/test/unit/stop-rm-idempotent.test.ts | 161 + packages/core/test/unit/store.test.ts | 111 + .../core/test/unit/strict-preflight.test.ts | 53 + packages/core/test/unit/translator.test.ts | 168 + packages/core/test/unit/which.test.ts | 29 + packages/core/test/wizard/wizard.test.tsx | 123 + packages/core/tsconfig.build.json | 4 + packages/core/tsconfig.json | 33 +- packages/core/vitest.config.ts | 8 + packages/wrapper/LICENSE | 21 + packages/wrapper/README.md | 243 +- packages/wrapper/bin.js | 16 +- packages/wrapper/package.json | 17 +- packages/wrapper/pnpm-lock.yaml | 620 ---- pnpm-lock.yaml | 1916 +++++++--- pnpm-workspace.yaml | 3 + 165 files changed, 11018 insertions(+), 7834 deletions(-) create mode 100644 LICENSE create mode 100644 packages/core/LICENSE delete mode 100644 packages/core/bin/dev.cmd mode change 100755 => 100644 packages/core/bin/dev.js delete mode 100644 packages/core/bin/run.cmd create mode 100644 packages/core/bin/skill-flag.js delete mode 100644 packages/core/pnpm-lock.yaml create mode 100644 packages/core/skill/SKILL.md create mode 100644 packages/core/src/base-command.ts create mode 100644 packages/core/src/cli/context.ts create mode 100644 packages/core/src/commands/agent.ts create mode 100644 packages/core/src/commands/attach.ts create mode 100644 packages/core/src/commands/build.ts create mode 100644 packages/core/src/commands/create.ts create mode 100644 packages/core/src/commands/engines.ts create mode 100644 packages/core/src/commands/exec.ts create mode 100644 packages/core/src/commands/logs.ts create mode 100644 packages/core/src/commands/ls.ts create mode 100644 packages/core/src/commands/rm.ts create mode 100644 packages/core/src/commands/schema.ts create mode 100644 packages/core/src/commands/shell.ts create mode 100644 packages/core/src/commands/skill.ts create mode 100644 packages/core/src/commands/ssh-proxy.ts create mode 100644 packages/core/src/commands/stop.ts create mode 100644 packages/core/src/commands/up.ts create mode 100644 packages/core/src/containerfile/base.ts create mode 100644 packages/core/src/containerfile/generate.ts create mode 100644 packages/core/src/containerfile/guard.ts create mode 100644 packages/core/src/containerfile/shims.ts create mode 100644 packages/core/src/core/build-pipeline.ts delete mode 100644 packages/core/src/core/devcontainer/devcontainerExec.ts delete mode 100644 packages/core/src/core/devcontainer/devcontainerUp.ts delete mode 100644 packages/core/src/core/devcontainer/index.ts delete mode 100644 packages/core/src/core/devcontainer/prebuiltList.ts delete mode 100644 packages/core/src/core/devcontainer/resolvePrebuiltPath.ts create mode 100644 packages/core/src/core/plan.ts delete mode 100644 packages/core/src/core/scripts/generate_dev_env.ts create mode 100644 packages/core/src/core/ssh/attach.ts create mode 100644 packages/core/src/core/ssh/editors.ts create mode 100644 packages/core/src/core/ssh/keys.ts create mode 100644 packages/core/src/core/ssh/provision.ts create mode 100644 packages/core/src/core/ssh/ssh-config.ts create mode 100644 packages/core/src/core/up-pipeline.ts delete mode 100644 packages/core/src/core/wizard/coreLanguages.ts delete mode 100644 packages/core/src/core/wizard/devcontainerName.ts delete mode 100644 packages/core/src/core/wizard/frameworks.ts delete mode 100644 packages/core/src/core/wizard/fuzzingTesting.ts delete mode 100644 packages/core/src/core/wizard/gitClone.ts delete mode 100644 packages/core/src/core/wizard/index.ts delete mode 100644 packages/core/src/core/wizard/languages.ts delete mode 100644 packages/core/src/core/wizard/savePath.ts delete mode 100644 packages/core/src/core/wizard/securityProfiles.ts delete mode 100644 packages/core/src/core/wizard/securityTooling.ts delete mode 100644 packages/core/src/core/wizard/systemHardening.ts delete mode 100644 packages/core/src/core/wizard/vscodeExtensions.ts delete mode 100644 packages/core/src/core/wizard/wizard.ts create mode 100644 packages/core/src/domain/catalog.ts create mode 100644 packages/core/src/domain/dependency-resolver.ts create mode 100644 packages/core/src/domain/hardening.ts rename packages/core/src/{core/scripts/install_commands.ts => domain/install-commands.ts} (72%) create mode 100644 packages/core/src/domain/normalize.ts create mode 100644 packages/core/src/domain/profiles.ts create mode 100644 packages/core/src/engine/drivers/apple-container.ts create mode 100644 packages/core/src/engine/drivers/capabilities.ts create mode 100644 packages/core/src/engine/drivers/cli-driver.ts create mode 100644 packages/core/src/engine/drivers/docker.ts create mode 100644 packages/core/src/engine/drivers/lima.ts create mode 100644 packages/core/src/engine/drivers/orbstack.ts create mode 100644 packages/core/src/engine/drivers/podman.ts create mode 100644 packages/core/src/engine/exec.ts create mode 100644 packages/core/src/engine/host.ts create mode 100644 packages/core/src/engine/registry.ts create mode 100644 packages/core/src/engine/resolver.ts create mode 100644 packages/core/src/engine/types.ts create mode 100644 packages/core/src/errors.ts create mode 100644 packages/core/src/hardening/effects.ts create mode 100644 packages/core/src/hardening/flag-emitters.ts create mode 100644 packages/core/src/hardening/translator.ts create mode 100644 packages/core/src/skill.ts create mode 100644 packages/core/src/spec/env-spec.ts create mode 100644 packages/core/src/spec/flags-to-spec.ts create mode 100644 packages/core/src/spec/schema-doc.ts create mode 100644 packages/core/src/state/manifest.ts create mode 100644 packages/core/src/state/paths.ts create mode 100644 packages/core/src/state/store.ts delete mode 100644 packages/core/src/types.d.ts delete mode 100644 packages/core/src/ui/components/checkboxWithTopDescription.ts delete mode 100644 packages/core/src/ui/components/confirmWithFooter.ts delete mode 100644 packages/core/src/ui/components/index.ts delete mode 100644 packages/core/src/ui/components/inputWithSymbols.ts delete mode 100644 packages/core/src/ui/components/selectWithTopDescription.ts delete mode 100644 packages/core/src/ui/styling/colors.ts delete mode 100644 packages/core/src/ui/styling/confirm.ts delete mode 100644 packages/core/src/ui/styling/index.ts delete mode 100644 packages/core/src/ui/styling/symbols.ts delete mode 100644 packages/core/src/ui/styling/ui.ts create mode 100644 packages/core/src/util/slug.ts create mode 100644 packages/core/src/util/which.ts delete mode 100644 packages/core/src/utils/openIn.ts delete mode 100644 packages/core/src/utils/shouldRun.ts delete mode 100644 packages/core/src/utils/versionCheck.ts create mode 100644 packages/core/src/wizard/App.tsx create mode 100644 packages/core/src/wizard/components/Banner.tsx create mode 100644 packages/core/src/wizard/components/MultiSelect.tsx create mode 100644 packages/core/src/wizard/components/Select.tsx create mode 100644 packages/core/src/wizard/components/TextInput.tsx create mode 100644 packages/core/src/wizard/hooks/useStepNav.ts create mode 100644 packages/core/src/wizard/run.tsx create mode 100644 packages/core/test/e2e/smoke.test.ts create mode 100644 packages/core/test/engine/fake-driver.ts create mode 100644 packages/core/test/engine/host.test.ts create mode 100644 packages/core/test/engine/resolver.test.ts create mode 100644 packages/core/test/unit/__snapshots__/containerfile.test.ts.snap create mode 100644 packages/core/test/unit/agent-airgap.test.ts create mode 100644 packages/core/test/unit/agent-env.test.ts create mode 100644 packages/core/test/unit/apple-container-caps.test.ts create mode 100644 packages/core/test/unit/apple-list-parse.test.ts create mode 100644 packages/core/test/unit/attach-ssh.test.ts create mode 100644 packages/core/test/unit/capabilities.test.ts create mode 100644 packages/core/test/unit/containerfile.test.ts create mode 100644 packages/core/test/unit/dependency-resolver.test.ts create mode 100644 packages/core/test/unit/env-name.test.ts create mode 100644 packages/core/test/unit/exec-env-resolution.test.ts create mode 100644 packages/core/test/unit/exec-secrets.test.ts create mode 100644 packages/core/test/unit/flags-to-spec.test.ts create mode 100644 packages/core/test/unit/guard.test.ts create mode 100644 packages/core/test/unit/hardening-report.test.ts create mode 100644 packages/core/test/unit/install-commands.test.ts create mode 100644 packages/core/test/unit/json-error-envelope.test.ts create mode 100644 packages/core/test/unit/json-flag.test.ts create mode 100644 packages/core/test/unit/logs-tail.test.ts create mode 100644 packages/core/test/unit/ls-reconcile.test.ts create mode 100644 packages/core/test/unit/manifest-migration.test.ts create mode 100644 packages/core/test/unit/pipelines.test.ts create mode 100644 packages/core/test/unit/profiles.test.ts create mode 100644 packages/core/test/unit/rm-no-engine.test.ts create mode 100644 packages/core/test/unit/schema-doc.test.ts create mode 100644 packages/core/test/unit/skill.test.ts create mode 100644 packages/core/test/unit/ssh-config.test.ts create mode 100644 packages/core/test/unit/ssh-keys.test.ts create mode 100644 packages/core/test/unit/ssh-proxy-invocation.test.ts create mode 100644 packages/core/test/unit/state-permissions.test.ts create mode 100644 packages/core/test/unit/stop-rm-idempotent.test.ts create mode 100644 packages/core/test/unit/store.test.ts create mode 100644 packages/core/test/unit/strict-preflight.test.ts create mode 100644 packages/core/test/unit/translator.test.ts create mode 100644 packages/core/test/unit/which.test.ts create mode 100644 packages/core/test/wizard/wizard.test.tsx create mode 100644 packages/core/tsconfig.build.json create mode 100644 packages/core/vitest.config.ts create mode 100644 packages/wrapper/LICENSE delete mode 100644 packages/wrapper/pnpm-lock.yaml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4a668bb --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The Red Guild + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 4fbcbf4..7c8ab00 100644 --- a/README.md +++ b/README.md @@ -1,232 +1,159 @@ -# DevContainer Wizard +# dcw — container environment wizard -A comprehensive CLI tool to set up fully equipped Web3 development containers. Features an interactive wizard for creating custom environments with advanced security hardening, git integration, and pre-configured toolchains, or quickly launch pre-built containers for common workflows. +An **editor-agnostic, shell-first** container environment wizard built on **oclif + ink**. It authors a Web3 dev environment (interactively or from flags), builds an image on whatever container engine you have, runs it **hardened**, and manages its lifecycle — for humans and agents alike. -> [!IMPORTANT] -> Dev Containers can improve your workflow, but they are **not a fully secure environment**. -> If you need to run untrusted or suspicious code, use GitHub Codespaces, GitPod, or a similar remote setup — **never run it directly on your machine**. - - -> [!CAUTION] -> **VS Code considerations:** -> -> VS Code does a lot to improve user experience, but that doesn't come without security tradeoffs. VS Code might allow API calls that can lead to running arbitrary commands on the host machine, and by default, it shares sockets such as the gpg-agent’s, which means keys stored outside the container can be used for signing. This opens the door to blind-signing commits scenarios, where a process inside the container may trigger signatures without the user’s full awareness. If you want to deep dive into these "tricks", we're working on an article covering the most relevant of them — stay tuned. - -![DevContainer Wizard](/assets/main.gif) - -## Requirements - -1. **Node.js 18+** and a package manager (**pnpm**, **npm**, or **yarn**) for installing the CLI. +As of v2 this tool does **not** generate `devcontainer.json`. It builds a plain-Debian image and runs a hardened container you `shell` into. Security hardening is translated into engine-correct `run` flags, degrading gracefully when an engine can't honor an option. -2. For use with [VS Code](https://code.visualstudio.com/) you need to install the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers). We recommend reading the [Dev Containers documentation](https://code.visualstudio.com/docs/devcontainers/containers) for more information. - -### Full requirements to run Dev Containers - -- **Operating system**: Linux, macOS, or Windows 10/11. On Windows, **WSL2** is recommended for best performance. -- **Container runtime**: One of the following: - - **Docker Desktop** (macOS/Windows) or **Docker Engine** (Linux) with the `docker` CLI available - - Alternatively, **Podman 4+** with the `podman-docker` shim to provide a `docker`-compatible CLI -- **Docker Compose v2**: Available as `docker compose` (bundled with Docker Desktop; on Linux install the Compose plugin). -- **Git**: Version 2.x or later. -- **Node.js 18+** and a package manager (**pnpm**, **npm**, or **yarn**) to install `@devcontainers/cli` globally. -- **Editor**: **VS Code** with the **Dev Containers** extension, or use **GitHub Codespaces** as an alternative (no local runtime required). -- **Permissions**: Ability to run containers (e.g., membership in the `docker` group on Linux, or run with `sudo`). -- **Network access**: To pull base images and extensions on first run. +> [!IMPORTANT] +> Containers improve your workflow, but they are **not a fully secure sandbox**. +> If you need to run untrusted or suspicious code, use GitHub Codespaces, GitPod, +> or a similar remote setup — **never run it directly on your machine**. ## Install -To install our pre-realease clone this repo and run: - -```bash -npm i -g devcontainer-wizard - -#or - -pnpm add -g devcontainer-wizard -``` - -## How to use - -### Quick start - -```bash -devcontainer-wizard +```sh +npm i -g @theredguild/devcontainer-wizard # or: pnpm add -g @theredguild/devcontainer-wizard ``` -### Create your own devcontainer - -![DevContainer Wizard](./assets/create.gif) - -```bash -devcontainer-wizard create --name +This installs two binaries — `dcw` and `devcontainer-wizard` — pointing at the same CLI. +Everything below uses `dcw`. + +> Already have v1 installed globally under the unscoped name? Either upgrade in place +> with `npm i -g devcontainer-wizard@latest`, or run `npm uninstall -g devcontainer-wizard` +> first — installing both packages globally fails with `EEXIST`, since they provide the +> same two binaries. + +## Upgrading from v1 + +v1 generated a `devcontainer.json` for VS Code. v2 does not: it builds and runs +hardened containers directly, and every command is new. There is no automatic +migration — v1 configs are not read. Coming from a VS Code Dev Containers workflow? +`dcw attach` is the v2 equivalent — it wires up SSH and launches your editor +(VS Code, Cursor, Zed, Antigravity, …) against the running container. Pin +`@theredguild/devcontainer-wizard@1` if you still need the old wizard. + +## Supported engines + +| Engine | Platform | Notes | +| --- | --- | --- | +| Docker | all | Full capability + resource control. Linux MAC (AppArmor) only on Linux hosts — not enforced in the macOS VM | +| OrbStack | macOS | Docker-compatible; auto-preferred on macOS | +| Podman | all | Rootless; uid-mapped tmpfs auto-uses `--userns=keep-id` | +| Lima (nerdctl) | macOS/Linux | AppArmor/sysctl depend on the guest VM | +| Apple Containers | macOS 15+ (arm64) | VM-isolated. Applies `--cap-drop`; **drops** read-only rootfs, tmpfs options, no-new-privileges, AppArmor and seccomp. Does **not** enforce network isolation (`--profile airgapped` stays networked unless you pass `--strict`) | + +`dcw engines` shows live availability + per-engine hardening trade-offs. + +## Quick start + +```sh +dcw create # interactive wizard (engine chosen first) +dcw build my-env # build the image +dcw up my-env # start a hardened container +dcw shell my-env # zsh into it (lands in /workspace as the vscode user) +dcw attach my-env # attach an SSH-remote editor (VS Code, Cursor, Zed, …) +dcw agent claude my-env # spawn an AI coding agent inside the container +dcw ls # list environments + live status +dcw stop my-env +dcw rm my-env --purge --yes ``` -The wizard will prompt you for: - -- **Devcontainer name**: defaults to the current directory name. -- **Languages**: Solidity, Vyper. -- **Frameworks**: Foundry, Hardhat, Ape (ApeWorX). -- **Fuzzing & testing**: Echidna, Medusa, Halmos, Ityfuzz, Aderyn. -- **Security tooling**: Slither, Mythril, Crytic (crytic-compile), Panoramix, Semgrep, Heimdall. -- **System hardening**: Choose between predefined security recipes or manual configuration: - - **Security Recipes**: Pre-configured security profiles for common use cases - - **Manual Configuration**: Fine-grained control over individual security options -- **Git repository integration**: Automatically clone a repository during container build - - Repository URL validation - - Optional branch/tag specification -- **VS Code extensions**: Choose from curated extension collections or select your own. -- **Save path**: where `.devcontainer/` will be created. - -When finished, the CLI writes `Dockerfile` and `devcontainer.json` to `.devcontainer/` and offers to start it immediately. It also prints the exact `devcontainer up` command you can run later. - -#### Security Profiles +## AI coding agents -The wizard includes predefined security profiles copied from prebuilt devcontainers, so you can build your own container with custom tools and a tested security profile: +Bake an agent CLI into the image by selecting it in the wizard ("AI coding agents" step) +or via `--ai-agent`, then launch it inside the running container with `dcw agent`: -- **Development**: Balanced security for daily development work - - *Features*: Secure temp directories, no privilege escalation, AppArmor, secure DNS, VS Code security +```sh +dcw create --name audit --framework foundry --ai-agent claude --build --up +dcw agent claude # opens Claude Code in the container, in /workspace -- **Hardened**: Ephemeral workspace without copying the host folder - - *Features*: Ephemeral workspace, maximum capability restrictions - -- **Air-gapped**: Hardened profile + no network - - *Features*: No network, ephemeral workspace, maximum capability restrictions - -- -Experimental profiles: - -- **Network Restricted Analysis**: API access and package installs without packet crafting -- **CI-like Local Runner**: Mirrors CI behavior with an immutable file system -- **Package Install Session**: Install packages while maintaining security guardrails -- **Security Research (Controlled Net)**: API testing without packet crafting capabilities - -#### Manual Security Hardening Options - -When choosing manual configuration, you have fine-grained control over: - -**File System Security**: -- Read-only file system -- Secure temp directories (noexec, nosuid flags) +dcw agent codex my-env -- --version # forward args after `--` +dcw agent opencode --env GITHUB_TOKEN # forward extra host env vars +dcw agent claude --install # install on-demand if not baked in +``` -**Workspace Isolation**: -- Ephemeral workspace (tmpfs mount) +Supported: `claude` (Anthropic Claude Code), `codex` (OpenAI Codex), `opencode` +(multi-provider). `dcw agent` forwards the matching provider key from your host — +`ANTHROPIC_API_KEY` for claude, `OPENAI_API_KEY` for codex, both for opencode (each agent +can also use its own login flow if no key is set). Agents need network: under `network-none` +hardening `dcw agent` warns (and fails under `--strict`). -**Container Security**: -- Drop all capabilities -- No new privileges (prevents SUID/SGID escalation) -- AppArmor profile +One-shot, non-interactive (agent-friendly): -**Network Configuration**: -- Enhanced DNS security (Cloudflare DNS) -- Complete network isolation -- Disable IPv6 -- Disable raw packets (prevents packet crafting) +```sh +dcw create --no-input --name audit \ + --core-lang rust --framework foundry --sec slither \ + --profile hardened --build --up --json +``` -**Application Security**: -- VS Code security (disables auto-tasks, workspace trust, telemetry) +## AI-native surface -**Resource Limits**: -- Light (512MB, 2 cores) -- Standard (2GB, 4 cores) -- Heavy (4GB, 8 cores) +- Every wizard step has a flag equivalent; `dcw create --no-input ...` never prompts. +- `--json` emits structured output and machine-readable errors (`{error:{code,message}}`) with deterministic exit codes. The streaming pass-through commands (`exec`, `shell`, `logs`, `agent`) have no JSON payload — they propagate the container's exit code and accept `--json` as a no-op. +- `--engine`, `--strict` (fail if hardening is dropped), `--yes`/`--no-input` are global. +- `dcw schema` dumps the JSON Schema of an environment spec plus the full option vocabulary (languages, frameworks, tools, profiles, hardening, engines) so agents can discover capabilities. +- `dcw --skill` (alias `dcw skill`) prints the bundled agent skill (`skill/SKILL.md`) — a concise guide teaching coding agents how to drive dcw. Install it once and refresh after upgrades: -#### Git Repository Integration + ```sh + mkdir -p ~/.claude/skills/dcw && dcw --skill > ~/.claude/skills/dcw/SKILL.md + ``` -The wizard can now automatically clone a git repository during container build: +## Hardening -- **Repository URL**: Supports `https://`, `git@`, `ssh://`, and `git://` protocols -- **Branch/Tag Selection**: Optionally specify a specific branch or tag to clone -- **Validation**: Built-in URL validation ensures proper git repository format -- **Build-time Integration**: Repository is cloned into `/home/vscode/repos` during the image build and copied into `/workspace` on first start +`dcw create` is **hardened by default**: with neither `--profile` nor `--harden`, it applies the `development` profile. Pass `--profile none` when you explicitly want no hardening. -This feature is particularly useful for: -- Setting up development environments with existing codebases -- Workshop environments with predefined project templates -- Audit environments with specific contract repositories +Pick a named profile (`--profile hardened`) or individual options (`--harden drop-caps --harden readonly-os`). Options map to engine-neutral effects, then to engine-correct flags. -#### VS Code Extensions +Engines degrade in two distinct ways, and both matter: -The wizard offers curated extension collections: +- **dropped** — the engine can't express the option at all, so it is never applied. +- **unenforced** — the flag is passed and accepted, but the engine can't actually enforce it. -- **Recommended** (default): Automatically installs Tintin's Ethereum Security Bundle -- **Custom selection**: Choose from organized collections: - - **Tintin's Extensions**: Security-focused tools (Ethereum Security Bundle, EthOver, WeAudit, Inline Bookmarks, Solidity Language Tools, Graphviz Preview, Decompiler) - - **Nomic Foundation**: Hardhat + Solidity integration - - **Olympix**: AI-powered smart contract analysis +Either is a warning by default and a hard failure (exit 7) under `--strict`. `dcw up --json` reports `appliedFlags`, `warnings`, `dropped` and `unenforced`; `dcw create --up --json` and `dcw attach --json` return the same data as a `hardening` object. A result showing `"dropped": []` may still have unenforced controls — check both. -### Start pre-built containers +`--strict` also covers commands that enter an *already-running* container (`exec`, `shell`, `agent`, `attach`): they refuse rather than drop you into an environment weaker than you asked for. -![DevContainer Wizard](./assets/prebuilt.gif) +> [!IMPORTANT] +> **AppArmor is not enforced on macOS.** Docker Desktop and OrbStack run containers inside a Linux VM whose daemon reports no AppArmor support — dcw probes this directly (`docker info` → `SecurityOptions`) rather than assuming it. The flag is still passed, so `apparmor` is reported as **`unenforced`**, not `dropped`. All four built-in profiles request it, so `--strict` fails closed on macOS for every one of them. -Prebuilt containers are stored in the [theredguild/devcontainer](https://github.com/theredguild/devcontainer) repository. +## State -- **Start a pre-built container**: +Environments live under XDG paths: -```bash -devcontainer-wizard prebuilt --name -``` +- `~/.config/dcw/environments/.json` — manifest (spec + resolved tools + image/container state) +- `~/.local/state/dcw//Containerfile` — generated build file -- **List available pre-built containers**: +## Development -```bash -devcontainer-wizard prebuilt --list +```sh +pnpm install +pnpm --filter @theredguild/devcontainer-wizard dev --help # run from source (tsx) +pnpm --filter @theredguild/devcontainer-wizard build # tsc → dist +pnpm --filter @theredguild/devcontainer-wizard test # unit + wizard tests (no daemon required) +pnpm --filter @theredguild/devcontainer-wizard test:e2e # gated: builds + drives a real engine ``` -- **Available pre-built containers**: `minimal`, `auditor`, `Hardened`, `paranoid`, `eth-security-toolbox`, `legacy`. -- You will be prompted how to open it (Terminal, VS Code, or Cursor). - -#### GitHub Codespaces - -You can also run prebuilt containers using GitHub Codespaces: - -[![Open in Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?hide_repo_select=true&ref=main&template_repository=theredguild/devcontainer) - -## Pre-built containers - -- **Minimal**: Use Hardhat and Foundry, doing zero config. -- **Auditor**: Audit smart contracts. -- **Hardened**: Use an Hardened workspace without copying your environment. -- **Air-gapped**: Air-gapped environment. -- **ETH Security Toolbox**: Auditor environment with Trail of Bits selected tools. -- **Legacy**: The Red Guild's original devcontainer. +ESM-only (`type: module`, NodeNext) — relative imports use explicit `.js` extensions. The ink wizard is loaded only on a real TTY; the JSON / non-interactive path never touches React. ## How to contribute -### Wizard - -This repo uses pnpm workspaces with the layout: +This repo is a pnpm workspace: -- `packages/core` → `@theredguild/devcontainer-wizard` (the actual CLI) -- `packages/wrapper` → `devcontainer-wizard` (thin wrapper that delegates to core) +- `packages/core` → `@theredguild/devcontainer-wizard` — the CLI +- `packages/wrapper` → `devcontainer-wizard` — thin alias that delegates to core Getting started: -- Install all deps and link workspaces: `pnpm install` (run at repo root) -- Run a script in a workspace: - - Core build: `pnpm --filter @theredguild/devcontainer-wizard build` - - Core dev (ts-node): `pnpm --filter @theredguild/devcontainer-wizard dev` -- Run the CLI locally: - - Via wrapper bin: `node packages/wrapper/bin.js` - - Or after install: `pnpx devcontainer-wizard` - -Developing wrapper against local core: - -- For local development, set the wrapper’s dependency to the workspace protocol so it links your local core: - - In `packages/wrapper/package.json`: `"@theredguild/devcontainer-wizard": "workspace:*"` -- Re-run `pnpm install` at the root to update links. +- `pnpm install` at the repo root installs and links everything +- `pnpm --filter @theredguild/devcontainer-wizard dev --help` runs the CLI from + source via tsx. Pass CLI args directly — **no** `--` separator, since pnpm + forwards a literal `--` to oclif and it errors. +- `node packages/wrapper/bin.js --help` exercises the unscoped wrapper against + your local core Notes: -- Root `package.json` declares `workspaces: ["packages/*"]` and `packageManager: "pnpm@8"`. -- The project uses pnpm as the primary package manager for workspace management. -- Package publishing is done securely using Github Actions and Trusted Publishers. Check [this article ↗](https://blog.theredguild.org/how-to-npm-and-avoid-getting-rekt/) for more information. - -### Pre-built containers - -We welcome contributions to the pre-built containers! To get started: - -1. **Fork the [theredguild/devcontainer](https://github.com/theredguild/devcontainer) repository** and clone it to your machine. -2. **Make your changes** in a new branch. -3. **Test your changes** locally. -4. **Commit and push** your branch. -5. **Open a pull request** with a clear description of your changes. +- Packages are published from GitHub Actions using npm Trusted Publishers, on + `v*` tags. See [this article ↗](https://blog.theredguild.org/how-to-npm-and-avoid-getting-rekt/). +- The wrapper depends on core via `workspace:*`, so it always links your local + build during development. diff --git a/package.json b/package.json index 99792b6..880c7ea 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,13 @@ "name": "devcontainer-wizard-monorepo", "private": true, "packageManager": "pnpm@10.18.0", + "scripts": { + "build": "pnpm --filter @theredguild/devcontainer-wizard build", + "test": "pnpm --filter @theredguild/devcontainer-wizard test", + "test:watch": "pnpm --filter @theredguild/devcontainer-wizard test:watch", + "test:e2e": "pnpm --filter @theredguild/devcontainer-wizard test:e2e", + "clean": "pnpm --filter @theredguild/devcontainer-wizard clean" + }, "dependencies": { "devcontainer-wizard": "workspace:*" } diff --git a/packages/core/.gitignore b/packages/core/.gitignore index fc4ecaa..dea7dfd 100644 --- a/packages/core/.gitignore +++ b/packages/core/.gitignore @@ -1,111 +1,4 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# Dependencies -/node_modules - -# Build output -/lib -/dist -/oclif.manifest.json -/tsconfig.tsbuildinfo - -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-temporary-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -jspm_packages/ - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variables file -.env -.env.test -.env.production -.env.local - -# parcel-bundler cache (https://parceljs.org/) -.cache - -# Mac files -.DS_Store -.AppleDouble -.LSOverride - -# Thumbnails -Thumbs.db -ehthumbs.db - -# Visual Studio Code -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -.history -node_modules -.env - -# Hardhat files -/cache -/artifacts - -# TypeChain files -/typechain -/typechain-types - -# solidity-coverage files -/coverage -/coverage.json - -# Hardhat Ignition default folder for deployments against a local node -ignition/deployments/chain-31337 +dist/ +node_modules/ +*.tsbuildinfo +oclif.manifest.json diff --git a/packages/core/LICENSE b/packages/core/LICENSE new file mode 100644 index 0000000..4a668bb --- /dev/null +++ b/packages/core/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The Red Guild + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/core/README.md b/packages/core/README.md index 12b6ffd..890cd5c 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,226 +1,136 @@ -# DevContainer Wizard +# dcw — container environment wizard -A comprehensive CLI tool to set up fully equipped Web3 development containers. Features an interactive wizard for creating custom environments with advanced security hardening, git integration, and pre-configured toolchains, or quickly launch pre-built containers for common workflows. +An **editor-agnostic, shell-first** container environment wizard built on **oclif + ink**. It authors a Web3 dev environment (interactively or from flags), builds an image on whatever container engine you have, runs it **hardened**, and manages its lifecycle — for humans and agents alike. -> [!IMPORTANT] -> Dev Containers can improve your workflow, but they are **not a fully secure environment**. -> If you need to run untrusted or suspicious code, use GitHub Codespaces, GitPod, or a similar remote setup — **never run it directly on your machine**. - - -> [!CAUTION] -> **VS Code considerations:** -> -> VS Code does a lot to improve user experience, but that doesn't come without security tradeoffs. VS Code might allow API calls that can lead to running arbitrary commands on the host machine, and by default, it shares sockets such as the gpg-agent’s, which means keys stored outside the container can be used for signing. This opens the door to blind-signing commits scenarios, where a process inside the container may trigger signatures without the user’s full awareness. If you want to deep dive into these "tricks", we're working on an article covering the most relevant of them — stay tuned. - -![DevContainer Wizard](/assets/main.gif) - -## Requirements - -1. **Node.js 18+** and a package manager (**pnpm**, **npm**, or **yarn**) for installing the CLI. +As of v2 this tool does **not** generate `devcontainer.json`. It builds a plain-Debian image and runs a hardened container you `shell` into. Security hardening is translated into engine-correct `run` flags, degrading gracefully when an engine can't honor an option. -2. For use with [VS Code](https://code.visualstudio.com/) you need to install the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers). We recommend reading the [Dev Containers documentation](https://code.visualstudio.com/docs/devcontainers/containers) for more information. - -### Full requirements to run Dev Containers - -- **Operating system**: Linux, macOS, or Windows 10/11. On Windows, **WSL2** is recommended for best performance. -- **Container runtime**: One of the following: - - **Docker Desktop** (macOS/Windows) or **Docker Engine** (Linux) with the `docker` CLI available - - Alternatively, **Podman 4+** with the `podman-docker` shim to provide a `docker`-compatible CLI -- **Docker Compose v2**: Available as `docker compose` (bundled with Docker Desktop; on Linux install the Compose plugin). -- **Git**: Version 2.x or later. -- **Node.js 18+** and a package manager (**pnpm**, **npm**, or **yarn**) to install `@devcontainers/cli` globally. -- **Editor**: **VS Code** with the **Dev Containers** extension, or use **GitHub Codespaces** as an alternative (no local runtime required). -- **Permissions**: Ability to run containers (e.g., membership in the `docker` group on Linux, or run with `sudo`). -- **Network access**: To pull base images and extensions on first run. +> [!IMPORTANT] +> Containers improve your workflow, but they are **not a fully secure sandbox**. +> If you need to run untrusted or suspicious code, use GitHub Codespaces, GitPod, +> or a similar remote setup — **never run it directly on your machine**. ## Install -To install our pre-realease clone this repo and run: - -```bash -npm i -g devcontainer-wizard - -#or - -pnpm add -g devcontainer-wizard +```sh +npm i -g @theredguild/devcontainer-wizard # or: pnpm add -g @theredguild/devcontainer-wizard ``` -## How to use - -### Quick start - -```bash -devcontainer-wizard -``` - -### Create your own devcontainer - -![DevContainer Wizard](./assets/create.gif) - -```bash -devcontainer-wizard create --name +This installs two binaries — `dcw` and `devcontainer-wizard` — pointing at the same CLI. +Everything below uses `dcw`. + +> Already have v1 installed globally under the unscoped name? Either upgrade in place +> with `npm i -g devcontainer-wizard@latest`, or run `npm uninstall -g devcontainer-wizard` +> first — installing both packages globally fails with `EEXIST`, since they provide the +> same two binaries. + +## Upgrading from v1 + +v1 generated a `devcontainer.json` for VS Code. v2 does not: it builds and runs +hardened containers directly, and every command is new. There is no automatic +migration — v1 configs are not read. Coming from a VS Code Dev Containers workflow? +`dcw attach` is the v2 equivalent — it wires up SSH and launches your editor +(VS Code, Cursor, Zed, Antigravity, …) against the running container. Pin +`@theredguild/devcontainer-wizard@1` if you still need the old wizard. + +## Supported engines + +| Engine | Platform | Notes | +| --- | --- | --- | +| Docker | all | Full capability + resource control. Linux MAC (AppArmor) only on Linux hosts — not enforced in the macOS VM | +| OrbStack | macOS | Docker-compatible; auto-preferred on macOS | +| Podman | all | Rootless; uid-mapped tmpfs auto-uses `--userns=keep-id` | +| Lima (nerdctl) | macOS/Linux | AppArmor/sysctl depend on the guest VM | +| Apple Containers | macOS 15+ (arm64) | VM-isolated. Applies `--cap-drop`; **drops** read-only rootfs, tmpfs options, no-new-privileges, AppArmor and seccomp. Does **not** enforce network isolation (`--profile airgapped` stays networked unless you pass `--strict`) | + +`dcw engines` shows live availability + per-engine hardening trade-offs. + +## Quick start + +```sh +dcw create # interactive wizard (engine chosen first) +dcw build my-env # build the image +dcw up my-env # start a hardened container +dcw shell my-env # zsh into it (lands in /workspace as the vscode user) +dcw attach my-env # attach an SSH-remote editor (VS Code, Cursor, Zed, …) +dcw agent claude my-env # spawn an AI coding agent inside the container +dcw ls # list environments + live status +dcw stop my-env +dcw rm my-env --purge --yes ``` -The wizard will prompt you for: - -- **Devcontainer name**: defaults to the current directory name. -- **Languages**: Solidity, Vyper. -- **Frameworks**: Foundry, Hardhat, Ape (ApeWorX). -- **Fuzzing & testing**: Echidna, Medusa, Halmos, Ityfuzz, Aderyn. -- **Security tooling**: Slither, Mythril, Crytic (crytic-compile), Panoramix, Semgrep, Heimdall. -- **System hardening**: Choose between predefined security recipes or manual configuration: - - **Security Recipes**: Pre-configured security profiles for common use cases - - **Manual Configuration**: Fine-grained control over individual security options -- **Git repository integration**: Automatically clone a repository during container build - - Repository URL validation - - Optional branch/tag specification -- **VS Code extensions**: Choose from curated extension collections or select your own. -- **Save path**: where `.devcontainer/` will be created. - -When finished, the CLI writes `Dockerfile` and `devcontainer.json` to `.devcontainer/` and offers to start it immediately. It also prints the exact `devcontainer up` command you can run later. - -#### Security Profiles - -The wizard includes predefined security profiles copied from prebuilt devcontainers, so you can build your own container with custom tools and a tested security profile: - -- **Development**: Balanced security for daily development work - - *Features*: Secure temp directories, no privilege escalation, AppArmor, secure DNS, VS Code security +## AI coding agents -- **Hardened**: Ephemeral workspace without copying the host folder - - *Features*: Ephemeral workspace, maximum capability restrictions +Bake an agent CLI into the image by selecting it in the wizard ("AI coding agents" step) +or via `--ai-agent`, then launch it inside the running container with `dcw agent`: -- **Air-gapped**: Hardened profile + no network - - *Features*: No network, ephemeral workspace, maximum capability restrictions +```sh +dcw create --name audit --framework foundry --ai-agent claude --build --up +dcw agent claude # opens Claude Code in the container, in /workspace -- -Experimental profiles: - -- **Network Restricted Analysis**: API access and package installs without packet crafting -- **CI-like Local Runner**: Mirrors CI behavior with an immutable file system -- **Package Install Session**: Install packages while maintaining security guardrails -- **Security Research (Controlled Net)**: API testing without packet crafting capabilities - -#### Manual Security Hardening Options - -When choosing manual configuration, you have fine-grained control over: - -**File System Security**: -- Read-only file system -- Secure temp directories (noexec, nosuid flags) - -**Workspace Isolation**: -- Ephemeral workspace (tmpfs mount) - -**Container Security**: -- Drop all capabilities -- No new privileges (prevents SUID/SGID escalation) -- AppArmor profile - -**Network Configuration**: -- Enhanced DNS security (Cloudflare DNS) -- Complete network isolation -- Disable IPv6 -- Disable raw packets (prevents packet crafting) - -**Application Security**: -- VS Code security (disables auto-tasks, workspace trust, telemetry) - -**Resource Limits**: -- Light (512MB, 2 cores) -- Standard (2GB, 4 cores) -- Heavy (4GB, 8 cores) - -#### Git Repository Integration - -The wizard can now automatically clone a git repository during container build: - -- **Repository URL**: Supports `https://`, `git@`, `ssh://`, and `git://` protocols -- **Branch/Tag Selection**: Optionally specify a specific branch or tag to clone -- **Validation**: Built-in URL validation ensures proper git repository format -- **Build-time Integration**: Repository is cloned into `/home/vscode/repos` during the image build and copied into `/workspace` on first start - -This feature is particularly useful for: -- Setting up development environments with existing codebases -- Workshop environments with predefined project templates -- Audit environments with specific contract repositories - -#### VS Code Extensions - -The wizard offers curated extension collections: - -- **Recommended** (default): Automatically installs Tintin's Ethereum Security Bundle -- **Custom selection**: Choose from organized collections: - - **Tintin's Extensions**: Security-focused tools (Ethereum Security Bundle, EthOver, WeAudit, Inline Bookmarks, Solidity Language Tools, Graphviz Preview, Decompiler) - - **Nomic Foundation**: Hardhat + Solidity integration - - **Olympix**: AI-powered smart contract analysis - -### Start pre-built containers - -![DevContainer Wizard](./assets/prebuilt.gif) +dcw agent codex my-env -- --version # forward args after `--` +dcw agent opencode --env GITHUB_TOKEN # forward extra host env vars +dcw agent claude --install # install on-demand if not baked in +``` -Prebuilt containers are stored in the [theredguild/devcontainer](https://github.com/theredguild/devcontainer) repository. +Supported: `claude` (Anthropic Claude Code), `codex` (OpenAI Codex), `opencode` +(multi-provider). `dcw agent` forwards the matching provider key from your host — +`ANTHROPIC_API_KEY` for claude, `OPENAI_API_KEY` for codex, both for opencode (each agent +can also use its own login flow if no key is set). Agents need network: under `network-none` +hardening `dcw agent` warns (and fails under `--strict`). -- **Start a pre-built container**: +One-shot, non-interactive (agent-friendly): -```bash -devcontainer-wizard prebuilt --name +```sh +dcw create --no-input --name audit \ + --core-lang rust --framework foundry --sec slither \ + --profile hardened --build --up --json ``` -- **List available pre-built containers**: - -```bash -devcontainer-wizard prebuilt --list -``` +## AI-native surface -- **Available pre-built containers**: `minimal`, `auditor`, `Hardened`, `paranoid`, `eth-security-toolbox`, `legacy`. -- You will be prompted how to open it (Terminal, VS Code, or Cursor). +- Every wizard step has a flag equivalent; `dcw create --no-input ...` never prompts. +- `--json` emits structured output and machine-readable errors (`{error:{code,message}}`) with deterministic exit codes. The streaming pass-through commands (`exec`, `shell`, `logs`, `agent`) have no JSON payload — they propagate the container's exit code and accept `--json` as a no-op. +- `--engine`, `--strict` (fail if hardening is dropped), `--yes`/`--no-input` are global. +- `dcw schema` dumps the JSON Schema of an environment spec plus the full option vocabulary (languages, frameworks, tools, profiles, hardening, engines) so agents can discover capabilities. +- `dcw --skill` (alias `dcw skill`) prints the bundled agent skill (`skill/SKILL.md`) — a concise guide teaching coding agents how to drive dcw. Install it once and refresh after upgrades: -#### GitHub Codespaces + ```sh + mkdir -p ~/.claude/skills/dcw && dcw --skill > ~/.claude/skills/dcw/SKILL.md + ``` -You can also run prebuilt containers using GitHub Codespaces: +## Hardening -[![Open in Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?hide_repo_select=true&ref=main&template_repository=theredguild/devcontainer) +`dcw create` is **hardened by default**: with neither `--profile` nor `--harden`, it applies the `development` profile. Pass `--profile none` when you explicitly want no hardening. -## Pre-built containers +Pick a named profile (`--profile hardened`) or individual options (`--harden drop-caps --harden readonly-os`). Options map to engine-neutral effects, then to engine-correct flags. -- **Minimal**: Use Hardhat and Foundry, doing zero config. -- **Auditor**: Audit smart contracts. -- **Hardened**: Use an Hardened workspace without copying your environment. -- **Air-gapped**: Air-gapped environment. -- **ETH Security Toolbox**: Auditor environment with Trail of Bits selected tools. -- **Legacy**: The Red Guild's original devcontainer. +Engines degrade in two distinct ways, and both matter: -## How to contribute +- **dropped** — the engine can't express the option at all, so it is never applied. +- **unenforced** — the flag is passed and accepted, but the engine can't actually enforce it. -### Wizard +Either is a warning by default and a hard failure (exit 7) under `--strict`. `dcw up --json` reports `appliedFlags`, `warnings`, `dropped` and `unenforced`; `dcw create --up --json` and `dcw attach --json` return the same data as a `hardening` object. A result showing `"dropped": []` may still have unenforced controls — check both. -We welcome contributions! To get started: +`--strict` also covers commands that enter an *already-running* container (`exec`, `shell`, `agent`, `attach`): they refuse rather than drop you into an environment weaker than you asked for. -1. **Fork this repository** and clone it to your machine. -2. **Install dependencies**: - ```bash - pnpm install - ``` -3. **Make your changes** in a new branch. -4. **Test your changes** locally. -5. **Commit and push** your branch. -6. **Open a pull request** with a clear description of your changes. +> [!IMPORTANT] +> **AppArmor is not enforced on macOS.** Docker Desktop and OrbStack run containers inside a Linux VM whose daemon reports no AppArmor support — dcw probes this directly (`docker info` → `SecurityOptions`) rather than assuming it. The flag is still passed, so `apparmor` is reported as **`unenforced`**, not `dropped`. All four built-in profiles request it, so `--strict` fails closed on macOS for every one of them. -For major changes, please open an issue first to discuss what you would like to change. +## State -**Tips:** -- Follow the existing code style and structure. -- Keep documentation concise and up to date. -- If adding a new color or symbol, update `src/ui/styling/colors.ts` or `src/ui/styling/symbols.ts` as appropriate. +Environments live under XDG paths: -Thank you for helping improve DevContainer Wizard! +- `~/.config/dcw/environments/.json` — manifest (spec + resolved tools + image/container state) +- `~/.local/state/dcw//Containerfile` — generated build file -### Pre-built containers +## Development -We welcome contributions to the pre-built containers! To get started: +```sh +pnpm install +pnpm --filter @theredguild/devcontainer-wizard dev --help # run from source (tsx) +pnpm --filter @theredguild/devcontainer-wizard build # tsc → dist +pnpm --filter @theredguild/devcontainer-wizard test # unit + wizard tests (no daemon required) +pnpm --filter @theredguild/devcontainer-wizard test:e2e # gated: builds + drives a real engine +``` -1. **Fork the [theredguild/devcontainer](https://github.com/theredguild/devcontainer) repository** and clone it to your machine. -2. **Make your changes** in a new branch. -3. **Test your changes** locally. -4. **Commit and push** your branch. -5. **Open a pull request** with a clear description of your changes. +ESM-only (`type: module`, NodeNext) — relative imports use explicit `.js` extensions. The ink wizard is loaded only on a real TTY; the JSON / non-interactive path never touches React. diff --git a/packages/core/bin/dev.cmd b/packages/core/bin/dev.cmd deleted file mode 100644 index 077b57a..0000000 --- a/packages/core/bin/dev.cmd +++ /dev/null @@ -1,3 +0,0 @@ -@echo off - -node "%~dp0\dev" %* \ No newline at end of file diff --git a/packages/core/bin/dev.js b/packages/core/bin/dev.js old mode 100755 new mode 100644 index c3852b4..3fb3dc3 --- a/packages/core/bin/dev.js +++ b/packages/core/bin/dev.js @@ -1,7 +1,8 @@ -#!/usr/bin/env node_modules/.bin/ts-node +#!/usr/bin/env node +// Dev entry: run via `node --import tsx bin/dev.js` so commands load from ./src. +import { execute } from '@oclif/core' +import { skillArgv } from './skill-flag.js' -// eslint-disable-next-line unicorn/prefer-top-level-await -;(async () => { - const oclif = await import('@oclif/core') - await oclif.execute({development: true, dir: __dirname}) -})() +process.env.NODE_ENV ??= 'development' + +await execute({ development: true, dir: import.meta.url, args: skillArgv(process.argv.slice(2)) }) diff --git a/packages/core/bin/run.cmd b/packages/core/bin/run.cmd deleted file mode 100644 index 968fc30..0000000 --- a/packages/core/bin/run.cmd +++ /dev/null @@ -1,3 +0,0 @@ -@echo off - -node "%~dp0\run" %* diff --git a/packages/core/bin/run.js b/packages/core/bin/run.js index 97b11a3..01095c5 100755 --- a/packages/core/bin/run.js +++ b/packages/core/bin/run.js @@ -1,7 +1,5 @@ #!/usr/bin/env node +import { execute } from '@oclif/core' +import { skillArgv } from './skill-flag.js' -// eslint-disable-next-line unicorn/prefer-top-level-await -;(async () => { - const oclif = await import('@oclif/core') - await oclif.execute({dir: __dirname}) -})() +await execute({ dir: import.meta.url, args: skillArgv(process.argv.slice(2)) }) diff --git a/packages/core/bin/skill-flag.js b/packages/core/bin/skill-flag.js new file mode 100644 index 0000000..ff62ec4 --- /dev/null +++ b/packages/core/bin/skill-flag.js @@ -0,0 +1,10 @@ +/** + * Top-level `dcw --skill` alias (inspired by `herdr --skill`). + * + * oclif has no root-level flags when commands live in a directory, so the bin + * entry rewrites `--skill` into the `skill` command before dispatch. Only a + * leading `--skill` is rewritten; any trailing args are preserved. + */ +export function skillArgv(argv) { + return argv[0] === '--skill' ? ['skill', ...argv.slice(1)] : argv +} diff --git a/packages/core/package.json b/packages/core/package.json index 5bff6d0..9d05546 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,56 +1,74 @@ { "name": "@theredguild/devcontainer-wizard", - "version": "1.2.1", - "description": "A comprehensive CLI tool to set up fully equipped Web3 development containers with advanced security hardening and git integration.", + "version": "2.0.0", + "description": "AI-native, multi-engine, shell-first container environment wizard for Web3 development and smart-contract auditing.", "license": "MIT", - "type": "commonjs", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "type": "module", + "types": "./dist/index.d.ts", "exports": { - ".": "./bin/run.js", - "./package.json": "./package.json", - "./bin/run.js": "./bin/run.js" - }, - "dependencies": { - "@inquirer/core": "^10.1.15", - "@inquirer/prompts": "^7.8.1", - "@oclif/core": "^4.5.2", - "semver": "^7.6.3" + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./bin/run.js": "./bin/run.js", + "./package.json": "./package.json" }, - "oclif": { - "bin": "devcontainer-wizard", - "commands": { "strategy": "single", "target": "./dist/index.js" }, - "tsconfig": "./tsconfig.json", - "dirname": "devcontainer-wizard", - "topicSeparator": " ", - "devCommands": "./src/commands" + "bin": { + "dcw": "./bin/run.js", + "devcontainer-wizard": "./bin/run.js" }, "files": [ - "bin/", - "dist/" + "bin", + "dist", + "skill", + "oclif.manifest.json", + "LICENSE" ], + "engines": { + "node": ">=18.17" + }, + "oclif": { + "bin": "dcw", + "dirname": "dcw", + "commands": "./dist/commands", + "topicSeparator": " ", + "additionalHelpFlags": [ + "-h" + ], + "additionalVersionFlags": [ + "-v" + ] + }, "scripts": { - "build": "tsc && tsc-alias", - "dev": "node ./bin/dev.js", - "prod": "pnpm run build && node ./bin/run.js", - "postpack": "rm -f oclif.manifest.json", - "prepack": "pnpm run build && oclif manifest && oclif readme" + "build": "tsc -p tsconfig.build.json", + "dev": "node --import tsx bin/dev.js", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "pnpm build && DCW_E2E=1 vitest run test/e2e", + "clean": "rm -rf dist", + "prepack": "pnpm run build && oclif manifest", + "postpack": "rm -f oclif.manifest.json" + }, + "dependencies": { + "@oclif/core": "^4.5.2", + "ink": "^5.1.0", + "react": "^18.3.1", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.23.5" }, "devDependencies": { - "@oclif/prettier-config": "^0.2.1", - "@types/node": "^18.19.122", - "@types/semver": "^7.5.8", + "@types/node": "^18.19.0", + "@types/react": "^18.3.12", + "ink-testing-library": "^4.0.0", "oclif": "^4.5.0", - "ts-node": "^10.9.2", - "tsc-alias": "^1.8.10", - "tsconfig-paths": "^4.2.0", - "typescript": "^5.9.2" + "tsx": "^4.19.2", + "typescript": "^5.9.2", + "vitest": "^2.1.8" }, - "publishConfig": { + "publishConfig": { "access": "public", "provenance": true }, - "engines": { "node": ">=18.17" }, "repository": { "type": "git", "url": "https://github.com/theredguild/devcontainer-wizard.git", diff --git a/packages/core/pnpm-lock.yaml b/packages/core/pnpm-lock.yaml deleted file mode 100644 index 7e2f0c7..0000000 --- a/packages/core/pnpm-lock.yaml +++ /dev/null @@ -1,3151 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@inquirer/core': - specifier: ^10.1.15 - version: 10.2.0(@types/node@18.19.124) - '@inquirer/prompts': - specifier: ^7.8.1 - version: 7.8.4(@types/node@18.19.124) - '@oclif/core': - specifier: ^4.5.2 - version: 4.5.3 - devDependencies: - '@oclif/prettier-config': - specifier: ^0.2.1 - version: 0.2.1 - '@types/node': - specifier: ^18.19.122 - version: 18.19.124 - oclif: - specifier: ^4.5.0 - version: 4.22.18(@types/node@18.19.124) - ts-node: - specifier: ^10.9.2 - version: 10.9.2(@types/node@18.19.124)(typescript@5.9.2) - tsc-alias: - specifier: ^1.8.10 - version: 1.8.16 - tsconfig-paths: - specifier: ^4.2.0 - version: 4.2.0 - typescript: - specifier: ^5.9.2 - version: 5.9.2 - -packages: - - '@aws-crypto/crc32@5.2.0': - resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} - engines: {node: '>=16.0.0'} - - '@aws-crypto/crc32c@5.2.0': - resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} - - '@aws-crypto/sha1-browser@5.2.0': - resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} - - '@aws-crypto/sha256-browser@5.2.0': - resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} - - '@aws-crypto/sha256-js@5.2.0': - resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} - engines: {node: '>=16.0.0'} - - '@aws-crypto/supports-web-crypto@5.2.0': - resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} - - '@aws-crypto/util@5.2.0': - resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - - '@aws-sdk/client-cloudfront@3.883.0': - resolution: {integrity: sha512-pchcrJZnnyw1p/+nkQrYYFnSF0L9fdPtmGVoWEKO02pTV1b6oOoIn4ORRr5fOZJibFDFnu3KGN+3KWMD1zta6A==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/client-s3@3.884.0': - resolution: {integrity: sha512-Okwg52/iho5wMCzd7A70g50k+bEYS+VUbSjD3NOzwrvZ3c7DwYjDUt13hVnUwMGygTVL+NGSpXSziTZe9P3/Cg==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/client-sso@3.883.0': - resolution: {integrity: sha512-Ybjw76yPceEBO7+VLjy5+/Gr0A1UNymSDHda5w8tfsS2iHZt/vuD6wrYpHdLoUx4H5la8ZhwcSfK/+kmE+QLPw==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/core@3.883.0': - resolution: {integrity: sha512-FmkqnqBLkXi4YsBPbF6vzPa0m4XKUuvgKDbamfw4DZX2CzfBZH6UU4IwmjNV3ZM38m0xraHarK8KIbGSadN3wg==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/credential-provider-env@3.883.0': - resolution: {integrity: sha512-Z6tPBXPCodfhIF1rvQKoeRGMkwL6TK0xdl1UoMIA1x4AfBpPICAF77JkFBExk/pdiFYq1d04Qzddd/IiujSlLg==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/credential-provider-http@3.883.0': - resolution: {integrity: sha512-P589ug1lMOOEYLTaQJjSP+Gee34za8Kk2LfteNQfO9SpByHFgGj++Sg8VyIe30eZL8Q+i4qTt24WDCz1c+dgYg==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/credential-provider-ini@3.883.0': - resolution: {integrity: sha512-n6z9HTzuDEdugXvPiE/95VJXbF4/gBffdV/SRHDJKtDHaRuvp/gggbfmfVSTFouGVnlKPb2pQWQsW3Nr/Y3Lrw==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/credential-provider-node@3.883.0': - resolution: {integrity: sha512-QIUhsatsrwfB9ZsKpmi0EySSfexVP61wgN7hr493DOileh2QsKW4XATEfsWNmx0dj9323Vg1Mix7bXtRfl9cGg==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/credential-provider-process@3.883.0': - resolution: {integrity: sha512-m1shbHY/Vppy4EdddG9r8x64TO/9FsCjokp5HbKcZvVoTOTgUJrdT8q2TAQJ89+zYIJDqsKbqfrmfwJ1zOdnGQ==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/credential-provider-sso@3.883.0': - resolution: {integrity: sha512-37ve9Tult08HLXrJFHJM/sGB/vO7wzI6v1RUUfeTiShqx8ZQ5fTzCTNY/duO96jCtCexmFNSycpQzh7lDIf0aA==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/credential-provider-web-identity@3.883.0': - resolution: {integrity: sha512-SL82K9Jb0vpuTadqTO4Fpdu7SKtebZ3Yo4LZvk/U0UauVMlJj5ZTos0mFx1QSMB9/4TpqifYrSZcdnxgYg8Eqw==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/middleware-bucket-endpoint@3.873.0': - resolution: {integrity: sha512-b4bvr0QdADeTUs+lPc9Z48kXzbKHXQKgTvxx/jXDgSW9tv4KmYPO1gIj6Z9dcrBkRWQuUtSW3Tu2S5n6pe+zeg==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/middleware-expect-continue@3.873.0': - resolution: {integrity: sha512-GIqoc8WgRcf/opBOZXFLmplJQKwOMjiOMmDz9gQkaJ8FiVJoAp8EGVmK2TOWZMQUYsavvHYsHaor5R2xwPoGVg==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/middleware-flexible-checksums@3.883.0': - resolution: {integrity: sha512-EloU4ZjkH+CXCHJcYElXo5nZ1vK6Miam/S02YSHk5JTrJkm4RV478KXXO29TIIAwZXcLT/FEQOZ9ZH/JHFFCFQ==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/middleware-host-header@3.873.0': - resolution: {integrity: sha512-KZ/W1uruWtMOs7D5j3KquOxzCnV79KQW9MjJFZM/M0l6KI8J6V3718MXxFHsTjUE4fpdV6SeCNLV1lwGygsjJA==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/middleware-location-constraint@3.873.0': - resolution: {integrity: sha512-r+hIaORsW/8rq6wieDordXnA/eAu7xAPLue2InhoEX6ML7irP52BgiibHLpt9R0psiCzIHhju8qqKa4pJOrmiw==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/middleware-logger@3.876.0': - resolution: {integrity: sha512-cpWJhOuMSyz9oV25Z/CMHCBTgafDCbv7fHR80nlRrPdPZ8ETNsahwRgltXP1QJJ8r3X/c1kwpOR7tc+RabVzNA==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/middleware-recursion-detection@3.873.0': - resolution: {integrity: sha512-OtgY8EXOzRdEWR//WfPkA/fXl0+WwE8hq0y9iw2caNyKPtca85dzrrZWnPqyBK/cpImosrpR1iKMYr41XshsCg==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/middleware-sdk-s3@3.883.0': - resolution: {integrity: sha512-i4sGOj9xhSN6/LkYj3AJ2SRWENnpN9JySwNqIoRqO1Uon8gfyNLJd1yV+s43vXQsU5wbKWVXK8l9SRo+vNTQwg==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/middleware-ssec@3.873.0': - resolution: {integrity: sha512-AF55J94BoiuzN7g3hahy0dXTVZahVi8XxRBLgzNp6yQf0KTng+hb/V9UQZVYY1GZaDczvvvnqC54RGe9OZZ9zQ==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/middleware-user-agent@3.883.0': - resolution: {integrity: sha512-q58uLYnGLg7hsnWpdj7Cd1Ulsq1/PUJOHvAfgcBuiDE/+Fwh0DZxZZyjrU+Cr+dbeowIdUaOO8BEDDJ0CUenJw==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/nested-clients@3.883.0': - resolution: {integrity: sha512-IhzDM+v0ga53GOOrZ9jmGNr7JU5OR6h6ZK9NgB7GXaa+gsDbqfUuXRwyKDYXldrTXf1sUR3vy1okWDXA7S2ejQ==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/region-config-resolver@3.873.0': - resolution: {integrity: sha512-q9sPoef+BBG6PJnc4x60vK/bfVwvRWsPgcoQyIra057S/QGjq5VkjvNk6H8xedf6vnKlXNBwq9BaANBXnldUJg==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/signature-v4-multi-region@3.883.0': - resolution: {integrity: sha512-86PO7+xhuQ48cD3xlZgEpRxVP1lBarWAJy23sB6zZLHgZSbnYXYjRFuyxX4PlFzqllM3PDKJvq3WnXeqSXeNsg==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/token-providers@3.883.0': - resolution: {integrity: sha512-tcj/Z5paGn9esxhmmkEW7gt39uNoIRbXG1UwJrfKu4zcTr89h86PDiIE2nxUO3CMQf1KgncPpr5WouPGzkh/QQ==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/types@3.862.0': - resolution: {integrity: sha512-Bei+RL0cDxxV+lW2UezLbCYYNeJm6Nzee0TpW0FfyTRBhH9C1XQh4+x+IClriXvgBnRquTMMYsmJfvx8iyLKrg==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/util-arn-parser@3.873.0': - resolution: {integrity: sha512-qag+VTqnJWDn8zTAXX4wiVioa0hZDQMtbZcGRERVnLar4/3/VIKBhxX2XibNQXFu1ufgcRn4YntT/XEPecFWcg==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/util-endpoints@3.879.0': - resolution: {integrity: sha512-aVAJwGecYoEmbEFju3127TyJDF9qJsKDUUTRMDuS8tGn+QiWQFnfInmbt+el9GU1gEJupNTXV+E3e74y51fb7A==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/util-locate-window@3.873.0': - resolution: {integrity: sha512-xcVhZF6svjM5Rj89T1WzkjQmrTF6dpR2UvIHPMTnSZoNe6CixejPZ6f0JJ2kAhO8H+dUHwNBlsUgOTIKiK/Syg==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/util-user-agent-browser@3.873.0': - resolution: {integrity: sha512-AcRdbK6o19yehEcywI43blIBhOCSo6UgyWcuOJX5CFF8k39xm1ILCjQlRRjchLAxWrm0lU0Q7XV90RiMMFMZtA==} - - '@aws-sdk/util-user-agent-node@3.883.0': - resolution: {integrity: sha512-28cQZqC+wsKUHGpTBr+afoIdjS6IoEJkMqcZsmo2Ag8LzmTa6BUWQenFYB0/9BmDy4PZFPUn+uX+rJgWKB+jzA==} - engines: {node: '>=18.0.0'} - peerDependencies: - aws-crt: '>=1.0.0' - peerDependenciesMeta: - aws-crt: - optional: true - - '@aws-sdk/xml-builder@3.873.0': - resolution: {integrity: sha512-kLO7k7cGJ6KaHiExSJWojZurF7SnGMDHXRuQunFnEoD0n1yB6Lqy/S/zHiQ7oJnBhPr9q0TW9qFkrsZb1Uc54w==} - engines: {node: '>=18.0.0'} - - '@cspotcode/source-map-support@0.8.1': - resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} - engines: {node: '>=12'} - - '@inquirer/checkbox@4.2.2': - resolution: {integrity: sha512-E+KExNurKcUJJdxmjglTl141EwxWyAHplvsYJQgSwXf8qiNWkTxTuCCqmhFEmbIXd4zLaGMfQFJ6WrZ7fSeV3g==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/confirm@3.2.0': - resolution: {integrity: sha512-oOIwPs0Dvq5220Z8lGL/6LHRTEr9TgLHmiI99Rj1PJ1p1czTys+olrgBqZk4E2qC0YTzeHprxSQmoHioVdJ7Lw==} - engines: {node: '>=18'} - - '@inquirer/confirm@5.1.16': - resolution: {integrity: sha512-j1a5VstaK5KQy8Mu8cHmuQvN1Zc62TbLhjJxwHvKPPKEoowSF6h/0UdOpA9DNdWZ+9Inq73+puRq1df6OJ8Sag==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/core@10.2.0': - resolution: {integrity: sha512-NyDSjPqhSvpZEMZrLCYUquWNl+XC/moEcVFqS55IEYIYsY0a1cUCevSqk7ctOlnm/RaSBU5psFryNlxcmGrjaA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/core@9.2.1': - resolution: {integrity: sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg==} - engines: {node: '>=18'} - - '@inquirer/editor@4.2.18': - resolution: {integrity: sha512-yeQN3AXjCm7+Hmq5L6Dm2wEDeBRdAZuyZ4I7tWSSanbxDzqM0KqzoDbKM7p4ebllAYdoQuPJS6N71/3L281i6w==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/expand@4.0.18': - resolution: {integrity: sha512-xUjteYtavH7HwDMzq4Cn2X4Qsh5NozoDHCJTdoXg9HfZ4w3R6mxV1B9tL7DGJX2eq/zqtsFjhm0/RJIMGlh3ag==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/external-editor@1.0.1': - resolution: {integrity: sha512-Oau4yL24d2B5IL4ma4UpbQigkVhzPDXLoqy1ggK4gnHg/stmkffJE4oOXHXF3uz0UEpywG68KcyXsyYpA1Re/Q==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/figures@1.0.13': - resolution: {integrity: sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==} - engines: {node: '>=18'} - - '@inquirer/input@2.3.0': - resolution: {integrity: sha512-XfnpCStx2xgh1LIRqPXrTNEEByqQWoxsWYzNRSEUxJ5c6EQlhMogJ3vHKu8aXuTacebtaZzMAHwEL0kAflKOBw==} - engines: {node: '>=18'} - - '@inquirer/input@4.2.2': - resolution: {integrity: sha512-hqOvBZj/MhQCpHUuD3MVq18SSoDNHy7wEnQ8mtvs71K8OPZVXJinOzcvQna33dNYLYE4LkA9BlhAhK6MJcsVbw==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/number@3.0.18': - resolution: {integrity: sha512-7exgBm52WXZRczsydCVftozFTrrwbG5ySE0GqUd2zLNSBXyIucs2Wnm7ZKLe/aUu6NUg9dg7Q80QIHCdZJiY4A==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/password@4.0.18': - resolution: {integrity: sha512-zXvzAGxPQTNk/SbT3carAD4Iqi6A2JS2qtcqQjsL22uvD+JfQzUrDEtPjLL7PLn8zlSNyPdY02IiQjzoL9TStA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/prompts@7.8.4': - resolution: {integrity: sha512-MuxVZ1en1g5oGamXV3DWP89GEkdD54alcfhHd7InUW5BifAdKQEK9SLFa/5hlWbvuhMPlobF0WAx7Okq988Jxg==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/rawlist@4.1.6': - resolution: {integrity: sha512-KOZqa3QNr3f0pMnufzL7K+nweFFCCBs6LCXZzXDrVGTyssjLeudn5ySktZYv1XiSqobyHRYYK0c6QsOxJEhXKA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/search@3.1.1': - resolution: {integrity: sha512-TkMUY+A2p2EYVY3GCTItYGvqT6LiLzHBnqsU1rJbrpXUijFfM6zvUx0R4civofVwFCmJZcKqOVwwWAjplKkhxA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/select@2.5.0': - resolution: {integrity: sha512-YmDobTItPP3WcEI86GvPo+T2sRHkxxOq/kXmsBjHS5BVXUgvgZ5AfJjkvQvZr03T81NnI3KrrRuMzeuYUQRFOA==} - engines: {node: '>=18'} - - '@inquirer/select@4.3.2': - resolution: {integrity: sha512-nwous24r31M+WyDEHV+qckXkepvihxhnyIaod2MG7eCE6G0Zm/HUF6jgN8GXgf4U7AU6SLseKdanY195cwvU6w==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/type@1.5.5': - resolution: {integrity: sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA==} - engines: {node: '>=18'} - - '@inquirer/type@2.0.0': - resolution: {integrity: sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag==} - engines: {node: '>=18'} - - '@inquirer/type@3.0.8': - resolution: {integrity: sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.9': - resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@oclif/core@4.5.3': - resolution: {integrity: sha512-ISoFlfmsuxJvNKXhabCO4/KqNXDQdLHchZdTPfZbtqAsQbqTw5IKitLVZq9Sz1LWizN37HILp4u0350B8scBjg==} - engines: {node: '>=18.0.0'} - - '@oclif/plugin-help@6.2.32': - resolution: {integrity: sha512-LrmMdo9EMJciOvF8UurdoTcTMymv5npKtxMAyonZvhSvGR8YwCKnuHIh00+SO2mNtGOYam7f4xHnUmj2qmanyA==} - engines: {node: '>=18.0.0'} - - '@oclif/plugin-not-found@3.2.67': - resolution: {integrity: sha512-Q2VluSwTrh7Sk0ey88Lk5WSATn9AZ6TjYQIyt2QrQolOBErAgpDoDSMVRYuVNtjxPBTDBzz4MM54QRFa/nN4IQ==} - engines: {node: '>=18.0.0'} - - '@oclif/plugin-warn-if-update-available@3.1.46': - resolution: {integrity: sha512-YDlr//SHmC80eZrt+0wNFWSo1cOSU60RoWdhSkAoPB3pUGPSNHZDquXDpo7KniinzYPsj1rfetCYk7UVXwYu7A==} - engines: {node: '>=18.0.0'} - - '@oclif/prettier-config@0.2.1': - resolution: {integrity: sha512-XB8kwQj8zynXjIIWRm+6gO/r8Qft2xKtwBMSmq1JRqtA6TpwpqECqiu8LosBCyg2JBXuUy2lU23/L98KIR7FrQ==} - - '@pnpm/config.env-replace@1.1.0': - resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} - engines: {node: '>=12.22.0'} - - '@pnpm/network.ca-file@1.0.2': - resolution: {integrity: sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==} - engines: {node: '>=12.22.0'} - - '@pnpm/npm-conf@2.3.1': - resolution: {integrity: sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==} - engines: {node: '>=12'} - - '@sindresorhus/is@5.6.0': - resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==} - engines: {node: '>=14.16'} - - '@smithy/abort-controller@4.1.0': - resolution: {integrity: sha512-wEhSYznxOmx7EdwK1tYEWJF5+/wmSFsff9BfTOn8oO/+KPl3gsmThrb6MJlWbOC391+Ya31s5JuHiC2RlT80Zg==} - engines: {node: '>=18.0.0'} - - '@smithy/chunked-blob-reader-native@4.1.0': - resolution: {integrity: sha512-Bnv0B3nSlfB2mPO0WgM49I/prl7+kamF042rrf3ezJ3Z4C7csPYvyYgZfXTGXwXfj1mAwDWjE/ybIf49PzFzvA==} - engines: {node: '>=18.0.0'} - - '@smithy/chunked-blob-reader@5.1.0': - resolution: {integrity: sha512-a36AtR7Q7XOhRPt6F/7HENmTWcB8kN7mDJcOFM/+FuKO6x88w8MQJfYCufMWh4fGyVkPjUh3Rrz/dnqFQdo6OQ==} - engines: {node: '>=18.0.0'} - - '@smithy/config-resolver@4.2.0': - resolution: {integrity: sha512-FA10YhPFLy23uxeWu7pOM2ctlw+gzbPMTZQwrZ8FRIfyJ/p8YIVz7AVTB5jjLD+QIerydyKcVMZur8qzzDILAQ==} - engines: {node: '>=18.0.0'} - - '@smithy/core@3.10.0': - resolution: {integrity: sha512-bXyD3Ij6b1qDymEYlEcF+QIjwb9gObwZNaRjETJsUEvSIzxFdynSQ3E4ysY7lUFSBzeWBNaFvX+5A0smbC2q6A==} - engines: {node: '>=18.0.0'} - - '@smithy/credential-provider-imds@4.1.0': - resolution: {integrity: sha512-iVwNhxTsCQTPdp++4C/d9xvaDmuEWhXi55qJobMp9QMaEHRGH3kErU4F8gohtdsawRqnUy/ANylCjKuhcR2mPw==} - engines: {node: '>=18.0.0'} - - '@smithy/eventstream-codec@4.1.0': - resolution: {integrity: sha512-MSOb6pwG3Tss1UwlZMHC+rYergWCo4fwep3Y1fJxwdLLxReSaKFfXxPQhEHi/8LSNQFEcBYBxybgjXjw4jJWqQ==} - engines: {node: '>=18.0.0'} - - '@smithy/eventstream-serde-browser@4.1.0': - resolution: {integrity: sha512-VvHXoBoLos2OCdMtUvKWK7ckcvun6ZP4KBYhf38+kszk6BEuK9k8c3xbIMIpC6K4vTK72qHlHAdBoR9qU+F7xw==} - engines: {node: '>=18.0.0'} - - '@smithy/eventstream-serde-config-resolver@4.2.0': - resolution: {integrity: sha512-T7YlcU0cP2bjAC4eXo9E6puqrrmqv5VHBL8bPMOMgEE1p4m+bwkDWRQpeiXqn/idoKM1qwXq8PvRLYmpbYB6uw==} - engines: {node: '>=18.0.0'} - - '@smithy/eventstream-serde-node@4.1.0': - resolution: {integrity: sha512-WlIKVRkcPjwuN3x+e8+5KOI9nL6s93bxgWH+39VwwQMl+4FagKPtTM3VCumSoZJ9qn/CNl4W5mVdFFRkDF84lQ==} - engines: {node: '>=18.0.0'} - - '@smithy/eventstream-serde-universal@4.1.0': - resolution: {integrity: sha512-GjMezHHd0xrjJcWLAcnXlVePe7PY8KsdxzKeXcMn7V3vfIScGUpKQJrlSmEXwzFH9Mjl0G0EdOS5GzewZEwtxg==} - engines: {node: '>=18.0.0'} - - '@smithy/fetch-http-handler@5.2.0': - resolution: {integrity: sha512-VZenjDdVaUGiy3hwQtxm75nhXZrhFG+3xyL93qCQAlYDyhT/jeDWM8/3r5uCFMlTmmyrIjiDyiOynVFchb0BSg==} - engines: {node: '>=18.0.0'} - - '@smithy/hash-blob-browser@4.1.0': - resolution: {integrity: sha512-brRgh2qEYPHYImfqoQB/xfcT/CjSz9Z/dH2vURSS0lIw3bImFK5t15l4iypwRw4GtZlZTK/VsLqsR54OJWRerg==} - engines: {node: '>=18.0.0'} - - '@smithy/hash-node@4.1.0': - resolution: {integrity: sha512-mXkJQ/6lAXTuoSsEH+d/fHa4ms4qV5LqYoPLYhmhCRTNcMMdg+4Ya8cMgU1W8+OR40eX0kzsExT7fAILqtTl2w==} - engines: {node: '>=18.0.0'} - - '@smithy/hash-stream-node@4.1.0': - resolution: {integrity: sha512-9TToqq62msanK/L6pV1ZAOm2+1VgCz9gE6/TVJhZXV352DnAItaO9jx6FFGujUDXrRJV0lpwe4c0vymz/vXMUQ==} - engines: {node: '>=18.0.0'} - - '@smithy/invalid-dependency@4.1.0': - resolution: {integrity: sha512-4/FcV6aCMzgpM4YyA/GRzTtG28G0RQJcWK722MmpIgzOyfSceWcI9T9c8matpHU9qYYLaWtk8pSGNCLn5kzDRw==} - engines: {node: '>=18.0.0'} - - '@smithy/is-array-buffer@2.2.0': - resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} - engines: {node: '>=14.0.0'} - - '@smithy/is-array-buffer@4.1.0': - resolution: {integrity: sha512-ePTYUOV54wMogio+he4pBybe8fwg4sDvEVDBU8ZlHOZXbXK3/C0XfJgUCu6qAZcawv05ZhZzODGUerFBPsPUDQ==} - engines: {node: '>=18.0.0'} - - '@smithy/md5-js@4.1.0': - resolution: {integrity: sha512-RW1+/E3rv80254ekFqiUTM8ExtN0dG9dkUwU2x17rxS4Mn2ib3SrTCdayCiNbfj6xWHupzgOJB6iNoXiOzNe6g==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-content-length@4.1.0': - resolution: {integrity: sha512-x3dgLFubk/ClKVniJu+ELeZGk4mq7Iv0HgCRUlxNUIcerHTLVmq7Q5eGJL0tOnUltY6KFw5YOKaYxwdcMwox/w==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-endpoint@4.2.0': - resolution: {integrity: sha512-J1eCF7pPDwgv7fGwRd2+Y+H9hlIolF3OZ2PjptonzzyOXXGh/1KGJAHpEcY1EX+WLlclKu2yC5k+9jWXdUG4YQ==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-retry@4.2.0': - resolution: {integrity: sha512-raL5oWYf5ALl3jCJrajE8enKJEnV/2wZkKS6mb3ZRY2tg3nj66ssdWy5Ps8E6Yu8Wqh3Tt+Sb9LozjvwZupq+A==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-serde@4.1.0': - resolution: {integrity: sha512-CtLFYlHt7c2VcztyVRc+25JLV4aGpmaSv9F1sPB0AGFL6S+RPythkqpGDa2XBQLJQooKkjLA1g7Xe4450knShg==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-stack@4.1.0': - resolution: {integrity: sha512-91Fuw4IKp0eK8PNhMXrHRcYA1jvbZ9BJGT91wwPy3bTQT8mHTcQNius/EhSQTlT9QUI3Ki1wjHeNXbWK0tO8YQ==} - engines: {node: '>=18.0.0'} - - '@smithy/node-config-provider@4.2.0': - resolution: {integrity: sha512-8/fpilqKurQ+f8nFvoFkJ0lrymoMJ+5/CQV5IcTv/MyKhk2Q/EFYCAgTSWHD4nMi9ux9NyBBynkyE9SLg2uSLA==} - engines: {node: '>=18.0.0'} - - '@smithy/node-http-handler@4.2.0': - resolution: {integrity: sha512-G4NV70B4hF9vBrUkkvNfWO6+QR4jYjeO4tc+4XrKCb4nPYj49V9Hu8Ftio7Mb0/0IlFyEOORudHrm+isY29nCA==} - engines: {node: '>=18.0.0'} - - '@smithy/property-provider@4.1.0': - resolution: {integrity: sha512-eksMjMHUlG5PwOUWO3k+rfLNOPVPJ70mUzyYNKb5lvyIuAwS4zpWGsxGiuT74DFWonW0xRNy+jgzGauUzX7SyA==} - engines: {node: '>=18.0.0'} - - '@smithy/protocol-http@5.2.0': - resolution: {integrity: sha512-bwjlh5JwdOQnA01be+5UvHK4HQz4iaRKlVG46hHSJuqi0Ribt3K06Z1oQ29i35Np4G9MCDgkOGcHVyLMreMcbg==} - engines: {node: '>=18.0.0'} - - '@smithy/querystring-builder@4.1.0': - resolution: {integrity: sha512-JqTWmVIq4AF8R8OK/2cCCiQo5ZJ0SRPsDkDgLO5/3z8xxuUp1oMIBBjfuueEe+11hGTZ6rRebzYikpKc6yQV9Q==} - engines: {node: '>=18.0.0'} - - '@smithy/querystring-parser@4.1.0': - resolution: {integrity: sha512-VgdHhr8YTRsjOl4hnKFm7xEMOCRTnKw3FJ1nU+dlWNhdt/7eEtxtkdrJdx7PlRTabdANTmvyjE4umUl9cK4awg==} - engines: {node: '>=18.0.0'} - - '@smithy/service-error-classification@4.1.0': - resolution: {integrity: sha512-UBpNFzBNmS20jJomuYn++Y+soF8rOK9AvIGjS9yGP6uRXF5rP18h4FDUsoNpWTlSsmiJ87e2DpZo9ywzSMH7PQ==} - engines: {node: '>=18.0.0'} - - '@smithy/shared-ini-file-loader@4.1.0': - resolution: {integrity: sha512-W0VMlz9yGdQ/0ZAgWICFjFHTVU0YSfGoCVpKaExRM/FDkTeP/yz8OKvjtGjs6oFokCRm0srgj/g4Cg0xuHu8Rw==} - engines: {node: '>=18.0.0'} - - '@smithy/signature-v4@5.2.0': - resolution: {integrity: sha512-ObX1ZqG2DdZQlXx9mLD7yAR8AGb7yXurGm+iWx9x4l1fBZ8CZN2BRT09aSbcXVPZXWGdn5VtMuupjxhOTI2EjA==} - engines: {node: '>=18.0.0'} - - '@smithy/smithy-client@4.6.0': - resolution: {integrity: sha512-TvlIshqx5PIi0I0AiR+PluCpJ8olVG++xbYkAIGCUkByaMUlfOXLgjQTmYbr46k4wuDe8eHiTIlUflnjK2drPQ==} - engines: {node: '>=18.0.0'} - - '@smithy/types@4.4.0': - resolution: {integrity: sha512-4jY91NgZz+ZnSFcVzWwngOW6VuK3gR/ihTwSU1R/0NENe9Jd8SfWgbhDCAGUWL3bI7DiDSW7XF6Ui6bBBjrqXw==} - engines: {node: '>=18.0.0'} - - '@smithy/url-parser@4.1.0': - resolution: {integrity: sha512-/LYEIOuO5B2u++tKr1NxNxhZTrr3A63jW8N73YTwVeUyAlbB/YM+hkftsvtKAcMt3ADYo0FsF1GY3anehffSVQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-base64@4.1.0': - resolution: {integrity: sha512-RUGd4wNb8GeW7xk+AY5ghGnIwM96V0l2uzvs/uVHf+tIuVX2WSvynk5CxNoBCsM2rQRSZElAo9rt3G5mJ/gktQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-body-length-browser@4.1.0': - resolution: {integrity: sha512-V2E2Iez+bo6bUMOTENPr6eEmepdY8Hbs+Uc1vkDKgKNA/brTJqOW/ai3JO1BGj9GbCeLqw90pbbH7HFQyFotGQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-body-length-node@4.1.0': - resolution: {integrity: sha512-BOI5dYjheZdgR9XiEM3HJcEMCXSoqbzu7CzIgYrx0UtmvtC3tC2iDGpJLsSRFffUpy8ymsg2ARMP5fR8mtuUQQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-buffer-from@2.2.0': - resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} - engines: {node: '>=14.0.0'} - - '@smithy/util-buffer-from@4.1.0': - resolution: {integrity: sha512-N6yXcjfe/E+xKEccWEKzK6M+crMrlwaCepKja0pNnlSkm6SjAeLKKA++er5Ba0I17gvKfN/ThV+ZOx/CntKTVw==} - engines: {node: '>=18.0.0'} - - '@smithy/util-config-provider@4.1.0': - resolution: {integrity: sha512-swXz2vMjrP1ZusZWVTB/ai5gK+J8U0BWvP10v9fpcFvg+Xi/87LHvHfst2IgCs1i0v4qFZfGwCmeD/KNCdJZbQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-defaults-mode-browser@4.1.0': - resolution: {integrity: sha512-D27cLtJtC4EEeERJXS+JPoogz2tE5zeE3zhWSSu6ER5/wJ5gihUxIzoarDX6K1U27IFTHit5YfHqU4Y9RSGE0w==} - engines: {node: '>=18.0.0'} - - '@smithy/util-defaults-mode-node@4.1.0': - resolution: {integrity: sha512-gnZo3u5dP1o87plKupg39alsbeIY1oFFnCyV2nI/++pL19vTtBLgOyftLEjPjuXmoKn2B2rskX8b7wtC/+3Okg==} - engines: {node: '>=18.0.0'} - - '@smithy/util-endpoints@3.1.0': - resolution: {integrity: sha512-5LFg48KkunBVGrNs3dnQgLlMXJLVo7k9sdZV5su3rjO3c3DmQ2LwUZI0Zr49p89JWK6sB7KmzyI2fVcDsZkwuw==} - engines: {node: '>=18.0.0'} - - '@smithy/util-hex-encoding@4.1.0': - resolution: {integrity: sha512-1LcueNN5GYC4tr8mo14yVYbh/Ur8jHhWOxniZXii+1+ePiIbsLZ5fEI0QQGtbRRP5mOhmooos+rLmVASGGoq5w==} - engines: {node: '>=18.0.0'} - - '@smithy/util-middleware@4.1.0': - resolution: {integrity: sha512-612onNcKyxhP7/YOTKFTb2F6sPYtMRddlT5mZvYf1zduzaGzkYhpYIPxIeeEwBZFjnvEqe53Ijl2cYEfJ9d6/Q==} - engines: {node: '>=18.0.0'} - - '@smithy/util-retry@4.1.0': - resolution: {integrity: sha512-5AGoBHb207xAKSVwaUnaER+L55WFY8o2RhlafELZR3mB0J91fpL+Qn+zgRkPzns3kccGaF2vy0HmNVBMWmN6dA==} - engines: {node: '>=18.0.0'} - - '@smithy/util-stream@4.3.0': - resolution: {integrity: sha512-ZOYS94jksDwvsCJtppHprUhsIscRnCKGr6FXCo3SxgQ31ECbza3wqDBqSy6IsAak+h/oAXb1+UYEBmDdseAjUQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-uri-escape@4.1.0': - resolution: {integrity: sha512-b0EFQkq35K5NHUYxU72JuoheM6+pytEVUGlTwiFxWFpmddA+Bpz3LgsPRIpBk8lnPE47yT7AF2Egc3jVnKLuPg==} - engines: {node: '>=18.0.0'} - - '@smithy/util-utf8@2.3.0': - resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} - engines: {node: '>=14.0.0'} - - '@smithy/util-utf8@4.1.0': - resolution: {integrity: sha512-mEu1/UIXAdNYuBcyEPbjScKi/+MQVXNIuY/7Cm5XLIWe319kDrT5SizBE95jqtmEXoDbGoZxKLCMttdZdqTZKQ==} - engines: {node: '>=18.0.0'} - - '@smithy/util-waiter@4.1.0': - resolution: {integrity: sha512-IUuj2zpGdeKaY5OdGnU83BUJsv7OA9uw3rNVSOuvzLMXMpBTU+W6V0SsQh6iI32lKUJArlnEU4BIzp83hghR/g==} - engines: {node: '>=18.0.0'} - - '@szmarczak/http-timer@5.0.1': - resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} - engines: {node: '>=14.16'} - - '@tsconfig/node10@1.0.11': - resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} - - '@tsconfig/node12@1.0.11': - resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} - - '@tsconfig/node14@1.0.3': - resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} - - '@tsconfig/node16@1.0.4': - resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - - '@types/http-cache-semantics@4.0.4': - resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==} - - '@types/mute-stream@0.0.4': - resolution: {integrity: sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==} - - '@types/node@18.19.124': - resolution: {integrity: sha512-hY4YWZFLs3ku6D2Gqo3RchTd9VRCcrjqp/I0mmohYeUVA5Y8eCXKJEasHxLAJVZRJuQogfd1GiJ9lgogBgKeuQ==} - - '@types/node@22.18.1': - resolution: {integrity: sha512-rzSDyhn4cYznVG+PCzGe1lwuMYJrcBS1fc3JqSa2PvtABwWo+dZ1ij5OVok3tqfpEBCBoaR4d7upFJk73HRJDw==} - - '@types/uuid@9.0.8': - resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} - - '@types/wrap-ansi@3.0.0': - resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} - - acorn-walk@8.3.4: - resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} - engines: {node: '>=0.4.0'} - - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} - engines: {node: '>=0.4.0'} - hasBin: true - - ansi-escapes@4.3.2: - resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} - engines: {node: '>=8'} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - ansis@3.17.0: - resolution: {integrity: sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==} - engines: {node: '>=14'} - - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - - arg@4.1.3: - resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} - - array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} - - async-retry@1.3.3: - resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} - - async@3.2.6: - resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} - - bowser@2.12.1: - resolution: {integrity: sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==} - - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - cacheable-lookup@7.0.0: - resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==} - engines: {node: '>=14.16'} - - cacheable-request@10.2.14: - resolution: {integrity: sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==} - engines: {node: '>=14.16'} - - camel-case@4.1.2: - resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} - - capital-case@1.0.4: - resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} - - change-case@4.1.2: - resolution: {integrity: sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==} - - chardet@2.1.0: - resolution: {integrity: sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==} - - chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} - engines: {node: '>= 8.10.0'} - - clean-stack@3.0.1: - resolution: {integrity: sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==} - engines: {node: '>=10'} - - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} - - cli-width@4.1.0: - resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} - engines: {node: '>= 12'} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - commander@9.5.0: - resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} - engines: {node: ^12.20.0 || >=14} - - config-chain@1.1.13: - resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} - - constant-case@3.0.4: - resolution: {integrity: sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==} - - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} - - create-require@1.1.1: - resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} - - debug@4.4.1: - resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decompress-response@6.0.0: - resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} - engines: {node: '>=10'} - - defer-to-connect@2.0.1: - resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} - engines: {node: '>=10'} - - detect-indent@7.0.1: - resolution: {integrity: sha512-Mc7QhQ8s+cLrnUfU/Ji94vG/r8M26m8f++vyres4ZoojaRDpZ1eSIh/EpzLNwlWuvzSZ3UbDFspjFvTDXe6e/g==} - engines: {node: '>=12.20'} - - detect-newline@4.0.1: - resolution: {integrity: sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - diff@4.0.2: - resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} - engines: {node: '>=0.3.1'} - - dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} - - dot-case@3.0.4: - resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} - - ejs@3.1.10: - resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} - engines: {node: '>=0.10.0'} - hasBin: true - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - error-ex@1.3.2: - resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - - fast-levenshtein@3.0.0: - resolution: {integrity: sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ==} - - fast-xml-parser@5.2.5: - resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==} - hasBin: true - - fastest-levenshtein@1.0.16: - resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} - engines: {node: '>= 4.9.1'} - - fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - filelist@1.0.4: - resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - find-yarn-workspace-root@2.0.0: - resolution: {integrity: sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==} - - form-data-encoder@2.1.4: - resolution: {integrity: sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==} - engines: {node: '>= 14.17'} - - fs-extra@8.1.0: - resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} - engines: {node: '>=6 <7 || >=8'} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - get-package-type@0.1.0: - resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} - engines: {node: '>=8.0.0'} - - get-stdin@9.0.0: - resolution: {integrity: sha512-dVKBjfWisLAicarI2Sf+JuBE/DghV4UzNAVe9yhEJuzeREd3JhOTE9cUaJTeSa77fsbQUK3pcOpJfM59+VKZaA==} - engines: {node: '>=12'} - - get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} - - get-tsconfig@4.10.1: - resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} - - git-hooks-list@3.2.0: - resolution: {integrity: sha512-ZHG9a1gEhUMX1TvGrLdyWb9kDopCBbTnI8z4JgRMYxsijWipgjSEYoPWqBuIB0DnRnvqlQSEeVmzpeuPm7NdFQ==} - - github-slugger@2.0.0: - resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} - - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - - globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} - - got@13.0.0: - resolution: {integrity: sha512-XfBk1CxOOScDcMr9O1yKkNaQyy865NbYs+F7dr4H0LZMVgCj2Le59k6PqbNHoL5ToeaEQUYh6c6yMfVcc6SJxA==} - engines: {node: '>=16'} - - graceful-fs@4.2.10: - resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - header-case@2.0.4: - resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==} - - hosted-git-info@7.0.2: - resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} - engines: {node: ^16.14.0 || >=18.0.0} - - http-cache-semantics@4.2.0: - resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} - - http-call@5.3.0: - resolution: {integrity: sha512-ahwimsC23ICE4kPl9xTBjKB4inbRaeLyZeRunC/1Jy/Z6X8tv22MEAjK+KBOMSVLaqXPTTmd8638waVIKLGx2w==} - engines: {node: '>=8.0.0'} - - http2-wrapper@2.2.1: - resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} - engines: {node: '>=10.19.0'} - - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - indent-string@4.0.0: - resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} - engines: {node: '>=8'} - - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - - is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - - is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} - - is-docker@2.2.1: - resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} - engines: {node: '>=8'} - hasBin: true - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-plain-obj@4.1.0: - resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} - engines: {node: '>=12'} - - is-retry-allowed@1.2.0: - resolution: {integrity: sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==} - engines: {node: '>=0.10.0'} - - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} - - is-wsl@2.2.0: - resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} - engines: {node: '>=8'} - - jake@10.9.4: - resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} - engines: {node: '>=10'} - hasBin: true - - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-parse-better-errors@1.0.2: - resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - jsonfile@4.0.0: - resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} - - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - - lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} - - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - - lower-case@2.0.2: - resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} - - lowercase-keys@3.0.0: - resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - - make-error@1.3.6: - resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} - - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - mimic-response@3.1.0: - resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} - engines: {node: '>=10'} - - mimic-response@4.0.0: - resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - minimatch@5.1.6: - resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} - engines: {node: '>=10'} - - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} - engines: {node: '>=16 || 14 >=14.17'} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - mute-stream@1.0.0: - resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - mute-stream@2.0.0: - resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} - engines: {node: ^18.17.0 || >=20.5.0} - - mylas@2.1.13: - resolution: {integrity: sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg==} - engines: {node: '>=12.0.0'} - - no-case@3.0.4: - resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} - - normalize-package-data@6.0.2: - resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} - engines: {node: ^16.14.0 || >=18.0.0} - - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - - normalize-url@8.0.2: - resolution: {integrity: sha512-Ee/R3SyN4BuynXcnTaekmaVdbDAEiNrHqjQIA37mHU8G9pf7aaAD4ZX3XjBLo6rsdcxA/gtkcNYZLt30ACgynw==} - engines: {node: '>=14.16'} - - oclif@4.22.18: - resolution: {integrity: sha512-5C3JWDZSQxJ2YxTMN4roSKF6s9LFcnDhPsTlBSVdJSk4iKjXqYAAR3s+d0JQLQWXFGq5wvBOkJcz4ft9iuWYvg==} - engines: {node: '>=18.0.0'} - hasBin: true - - p-cancelable@3.0.0: - resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==} - engines: {node: '>=12.20'} - - param-case@3.0.4: - resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} - - parse-json@4.0.0: - resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} - engines: {node: '>=4'} - - pascal-case@3.1.2: - resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} - - path-case@3.0.4: - resolution: {integrity: sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==} - - path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - - plimit-lit@1.6.1: - resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==} - engines: {node: '>=12'} - - proto-list@1.2.4: - resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} - - queue-lit@1.5.2: - resolution: {integrity: sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==} - engines: {node: '>=12'} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - - quick-lru@5.1.1: - resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} - engines: {node: '>=10'} - - readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} - - registry-auth-token@5.1.0: - resolution: {integrity: sha512-GdekYuwLXLxMuFTwAPg5UKGLW/UXzQrZvH/Zj791BQif5T05T0RsaLfHc9q3ZOKi7n+BoprPD9mJ0O0k4xzUlw==} - engines: {node: '>=14'} - - resolve-alpn@1.2.1: - resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} - - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - - responselike@3.0.0: - resolution: {integrity: sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==} - engines: {node: '>=14.16'} - - retry@0.13.1: - resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} - engines: {node: '>= 4'} - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - semver@7.7.2: - resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} - engines: {node: '>=10'} - hasBin: true - - sentence-case@3.0.4: - resolution: {integrity: sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==} - - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - - snake-case@3.0.4: - resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} - - sort-object-keys@1.1.3: - resolution: {integrity: sha512-855pvK+VkU7PaKYPc+Jjnmt4EzejQHyhhF33q31qG8x7maDzkeFhAAThdCYay11CISO+qAMwjOBP+fPZe0IPyg==} - - sort-package-json@2.15.1: - resolution: {integrity: sha512-9x9+o8krTT2saA9liI4BljNjwAbvUnWf11Wq+i/iZt8nl2UGYnf3TH5uBydE7VALmP7AGwlfszuEeL8BDyb0YA==} - hasBin: true - - spdx-correct@3.2.0: - resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} - - spdx-exceptions@2.5.0: - resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} - - spdx-expression-parse@3.0.1: - resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} - - spdx-license-ids@3.0.22: - resolution: {integrity: sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-bom@3.0.0: - resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} - engines: {node: '>=4'} - - strnum@2.1.1: - resolution: {integrity: sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==} - - supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} - - tiny-jsonc@1.0.2: - resolution: {integrity: sha512-f5QDAfLq6zIVSyCZQZhhyl0QS6MvAyTxgz4X4x3+EoCktNWEYJ6PeoEA97fyb98njpBNNi88ybpD7m+BDFXaCw==} - - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - ts-node@10.9.2: - resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} - hasBin: true - peerDependencies: - '@swc/core': '>=1.2.50' - '@swc/wasm': '>=1.2.50' - '@types/node': '*' - typescript: '>=2.7' - peerDependenciesMeta: - '@swc/core': - optional: true - '@swc/wasm': - optional: true - - tsc-alias@1.8.16: - resolution: {integrity: sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==} - engines: {node: '>=16.20.2'} - hasBin: true - - tsconfig-paths@4.2.0: - resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} - engines: {node: '>=6'} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - tunnel-agent@0.6.0: - resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - - type-fest@0.21.3: - resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} - engines: {node: '>=10'} - - typescript@5.9.2: - resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@5.26.5: - resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - - universalify@0.1.2: - resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} - engines: {node: '>= 4.0.0'} - - upper-case-first@2.0.2: - resolution: {integrity: sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==} - - upper-case@2.0.2: - resolution: {integrity: sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==} - - uuid@9.0.1: - resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} - hasBin: true - - v8-compile-cache-lib@3.0.1: - resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} - - validate-npm-package-license@3.0.4: - resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} - - validate-npm-package-name@5.0.1: - resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - widest-line@3.1.0: - resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} - engines: {node: '>=8'} - - wordwrap@1.0.0: - resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - - wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - yn@3.1.1: - resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} - engines: {node: '>=6'} - - yoctocolors-cjs@2.1.3: - resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} - engines: {node: '>=18'} - -snapshots: - - '@aws-crypto/crc32@5.2.0': - dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.862.0 - tslib: 2.8.1 - - '@aws-crypto/crc32c@5.2.0': - dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.862.0 - tslib: 2.8.1 - - '@aws-crypto/sha1-browser@5.2.0': - dependencies: - '@aws-crypto/supports-web-crypto': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.862.0 - '@aws-sdk/util-locate-window': 3.873.0 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - - '@aws-crypto/sha256-browser@5.2.0': - dependencies: - '@aws-crypto/sha256-js': 5.2.0 - '@aws-crypto/supports-web-crypto': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.862.0 - '@aws-sdk/util-locate-window': 3.873.0 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - - '@aws-crypto/sha256-js@5.2.0': - dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.862.0 - tslib: 2.8.1 - - '@aws-crypto/supports-web-crypto@5.2.0': - dependencies: - tslib: 2.8.1 - - '@aws-crypto/util@5.2.0': - dependencies: - '@aws-sdk/types': 3.862.0 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - - '@aws-sdk/client-cloudfront@3.883.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.883.0 - '@aws-sdk/credential-provider-node': 3.883.0 - '@aws-sdk/middleware-host-header': 3.873.0 - '@aws-sdk/middleware-logger': 3.876.0 - '@aws-sdk/middleware-recursion-detection': 3.873.0 - '@aws-sdk/middleware-user-agent': 3.883.0 - '@aws-sdk/region-config-resolver': 3.873.0 - '@aws-sdk/types': 3.862.0 - '@aws-sdk/util-endpoints': 3.879.0 - '@aws-sdk/util-user-agent-browser': 3.873.0 - '@aws-sdk/util-user-agent-node': 3.883.0 - '@aws-sdk/xml-builder': 3.873.0 - '@smithy/config-resolver': 4.2.0 - '@smithy/core': 3.10.0 - '@smithy/fetch-http-handler': 5.2.0 - '@smithy/hash-node': 4.1.0 - '@smithy/invalid-dependency': 4.1.0 - '@smithy/middleware-content-length': 4.1.0 - '@smithy/middleware-endpoint': 4.2.0 - '@smithy/middleware-retry': 4.2.0 - '@smithy/middleware-serde': 4.1.0 - '@smithy/middleware-stack': 4.1.0 - '@smithy/node-config-provider': 4.2.0 - '@smithy/node-http-handler': 4.2.0 - '@smithy/protocol-http': 5.2.0 - '@smithy/smithy-client': 4.6.0 - '@smithy/types': 4.4.0 - '@smithy/url-parser': 4.1.0 - '@smithy/util-base64': 4.1.0 - '@smithy/util-body-length-browser': 4.1.0 - '@smithy/util-body-length-node': 4.1.0 - '@smithy/util-defaults-mode-browser': 4.1.0 - '@smithy/util-defaults-mode-node': 4.1.0 - '@smithy/util-endpoints': 3.1.0 - '@smithy/util-middleware': 4.1.0 - '@smithy/util-retry': 4.1.0 - '@smithy/util-stream': 4.3.0 - '@smithy/util-utf8': 4.1.0 - '@smithy/util-waiter': 4.1.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/client-s3@3.884.0': - dependencies: - '@aws-crypto/sha1-browser': 5.2.0 - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.883.0 - '@aws-sdk/credential-provider-node': 3.883.0 - '@aws-sdk/middleware-bucket-endpoint': 3.873.0 - '@aws-sdk/middleware-expect-continue': 3.873.0 - '@aws-sdk/middleware-flexible-checksums': 3.883.0 - '@aws-sdk/middleware-host-header': 3.873.0 - '@aws-sdk/middleware-location-constraint': 3.873.0 - '@aws-sdk/middleware-logger': 3.876.0 - '@aws-sdk/middleware-recursion-detection': 3.873.0 - '@aws-sdk/middleware-sdk-s3': 3.883.0 - '@aws-sdk/middleware-ssec': 3.873.0 - '@aws-sdk/middleware-user-agent': 3.883.0 - '@aws-sdk/region-config-resolver': 3.873.0 - '@aws-sdk/signature-v4-multi-region': 3.883.0 - '@aws-sdk/types': 3.862.0 - '@aws-sdk/util-endpoints': 3.879.0 - '@aws-sdk/util-user-agent-browser': 3.873.0 - '@aws-sdk/util-user-agent-node': 3.883.0 - '@aws-sdk/xml-builder': 3.873.0 - '@smithy/config-resolver': 4.2.0 - '@smithy/core': 3.10.0 - '@smithy/eventstream-serde-browser': 4.1.0 - '@smithy/eventstream-serde-config-resolver': 4.2.0 - '@smithy/eventstream-serde-node': 4.1.0 - '@smithy/fetch-http-handler': 5.2.0 - '@smithy/hash-blob-browser': 4.1.0 - '@smithy/hash-node': 4.1.0 - '@smithy/hash-stream-node': 4.1.0 - '@smithy/invalid-dependency': 4.1.0 - '@smithy/md5-js': 4.1.0 - '@smithy/middleware-content-length': 4.1.0 - '@smithy/middleware-endpoint': 4.2.0 - '@smithy/middleware-retry': 4.2.0 - '@smithy/middleware-serde': 4.1.0 - '@smithy/middleware-stack': 4.1.0 - '@smithy/node-config-provider': 4.2.0 - '@smithy/node-http-handler': 4.2.0 - '@smithy/protocol-http': 5.2.0 - '@smithy/smithy-client': 4.6.0 - '@smithy/types': 4.4.0 - '@smithy/url-parser': 4.1.0 - '@smithy/util-base64': 4.1.0 - '@smithy/util-body-length-browser': 4.1.0 - '@smithy/util-body-length-node': 4.1.0 - '@smithy/util-defaults-mode-browser': 4.1.0 - '@smithy/util-defaults-mode-node': 4.1.0 - '@smithy/util-endpoints': 3.1.0 - '@smithy/util-middleware': 4.1.0 - '@smithy/util-retry': 4.1.0 - '@smithy/util-stream': 4.3.0 - '@smithy/util-utf8': 4.1.0 - '@smithy/util-waiter': 4.1.0 - '@types/uuid': 9.0.8 - tslib: 2.8.1 - uuid: 9.0.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/client-sso@3.883.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.883.0 - '@aws-sdk/middleware-host-header': 3.873.0 - '@aws-sdk/middleware-logger': 3.876.0 - '@aws-sdk/middleware-recursion-detection': 3.873.0 - '@aws-sdk/middleware-user-agent': 3.883.0 - '@aws-sdk/region-config-resolver': 3.873.0 - '@aws-sdk/types': 3.862.0 - '@aws-sdk/util-endpoints': 3.879.0 - '@aws-sdk/util-user-agent-browser': 3.873.0 - '@aws-sdk/util-user-agent-node': 3.883.0 - '@smithy/config-resolver': 4.2.0 - '@smithy/core': 3.10.0 - '@smithy/fetch-http-handler': 5.2.0 - '@smithy/hash-node': 4.1.0 - '@smithy/invalid-dependency': 4.1.0 - '@smithy/middleware-content-length': 4.1.0 - '@smithy/middleware-endpoint': 4.2.0 - '@smithy/middleware-retry': 4.2.0 - '@smithy/middleware-serde': 4.1.0 - '@smithy/middleware-stack': 4.1.0 - '@smithy/node-config-provider': 4.2.0 - '@smithy/node-http-handler': 4.2.0 - '@smithy/protocol-http': 5.2.0 - '@smithy/smithy-client': 4.6.0 - '@smithy/types': 4.4.0 - '@smithy/url-parser': 4.1.0 - '@smithy/util-base64': 4.1.0 - '@smithy/util-body-length-browser': 4.1.0 - '@smithy/util-body-length-node': 4.1.0 - '@smithy/util-defaults-mode-browser': 4.1.0 - '@smithy/util-defaults-mode-node': 4.1.0 - '@smithy/util-endpoints': 3.1.0 - '@smithy/util-middleware': 4.1.0 - '@smithy/util-retry': 4.1.0 - '@smithy/util-utf8': 4.1.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/core@3.883.0': - dependencies: - '@aws-sdk/types': 3.862.0 - '@aws-sdk/xml-builder': 3.873.0 - '@smithy/core': 3.10.0 - '@smithy/node-config-provider': 4.2.0 - '@smithy/property-provider': 4.1.0 - '@smithy/protocol-http': 5.2.0 - '@smithy/signature-v4': 5.2.0 - '@smithy/smithy-client': 4.6.0 - '@smithy/types': 4.4.0 - '@smithy/util-base64': 4.1.0 - '@smithy/util-body-length-browser': 4.1.0 - '@smithy/util-middleware': 4.1.0 - '@smithy/util-utf8': 4.1.0 - fast-xml-parser: 5.2.5 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-env@3.883.0': - dependencies: - '@aws-sdk/core': 3.883.0 - '@aws-sdk/types': 3.862.0 - '@smithy/property-provider': 4.1.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-http@3.883.0': - dependencies: - '@aws-sdk/core': 3.883.0 - '@aws-sdk/types': 3.862.0 - '@smithy/fetch-http-handler': 5.2.0 - '@smithy/node-http-handler': 4.2.0 - '@smithy/property-provider': 4.1.0 - '@smithy/protocol-http': 5.2.0 - '@smithy/smithy-client': 4.6.0 - '@smithy/types': 4.4.0 - '@smithy/util-stream': 4.3.0 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-ini@3.883.0': - dependencies: - '@aws-sdk/core': 3.883.0 - '@aws-sdk/credential-provider-env': 3.883.0 - '@aws-sdk/credential-provider-http': 3.883.0 - '@aws-sdk/credential-provider-process': 3.883.0 - '@aws-sdk/credential-provider-sso': 3.883.0 - '@aws-sdk/credential-provider-web-identity': 3.883.0 - '@aws-sdk/nested-clients': 3.883.0 - '@aws-sdk/types': 3.862.0 - '@smithy/credential-provider-imds': 4.1.0 - '@smithy/property-provider': 4.1.0 - '@smithy/shared-ini-file-loader': 4.1.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-node@3.883.0': - dependencies: - '@aws-sdk/credential-provider-env': 3.883.0 - '@aws-sdk/credential-provider-http': 3.883.0 - '@aws-sdk/credential-provider-ini': 3.883.0 - '@aws-sdk/credential-provider-process': 3.883.0 - '@aws-sdk/credential-provider-sso': 3.883.0 - '@aws-sdk/credential-provider-web-identity': 3.883.0 - '@aws-sdk/types': 3.862.0 - '@smithy/credential-provider-imds': 4.1.0 - '@smithy/property-provider': 4.1.0 - '@smithy/shared-ini-file-loader': 4.1.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-process@3.883.0': - dependencies: - '@aws-sdk/core': 3.883.0 - '@aws-sdk/types': 3.862.0 - '@smithy/property-provider': 4.1.0 - '@smithy/shared-ini-file-loader': 4.1.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-sso@3.883.0': - dependencies: - '@aws-sdk/client-sso': 3.883.0 - '@aws-sdk/core': 3.883.0 - '@aws-sdk/token-providers': 3.883.0 - '@aws-sdk/types': 3.862.0 - '@smithy/property-provider': 4.1.0 - '@smithy/shared-ini-file-loader': 4.1.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-web-identity@3.883.0': - dependencies: - '@aws-sdk/core': 3.883.0 - '@aws-sdk/nested-clients': 3.883.0 - '@aws-sdk/types': 3.862.0 - '@smithy/property-provider': 4.1.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/middleware-bucket-endpoint@3.873.0': - dependencies: - '@aws-sdk/types': 3.862.0 - '@aws-sdk/util-arn-parser': 3.873.0 - '@smithy/node-config-provider': 4.2.0 - '@smithy/protocol-http': 5.2.0 - '@smithy/types': 4.4.0 - '@smithy/util-config-provider': 4.1.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-expect-continue@3.873.0': - dependencies: - '@aws-sdk/types': 3.862.0 - '@smithy/protocol-http': 5.2.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-flexible-checksums@3.883.0': - dependencies: - '@aws-crypto/crc32': 5.2.0 - '@aws-crypto/crc32c': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/core': 3.883.0 - '@aws-sdk/types': 3.862.0 - '@smithy/is-array-buffer': 4.1.0 - '@smithy/node-config-provider': 4.2.0 - '@smithy/protocol-http': 5.2.0 - '@smithy/types': 4.4.0 - '@smithy/util-middleware': 4.1.0 - '@smithy/util-stream': 4.3.0 - '@smithy/util-utf8': 4.1.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-host-header@3.873.0': - dependencies: - '@aws-sdk/types': 3.862.0 - '@smithy/protocol-http': 5.2.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-location-constraint@3.873.0': - dependencies: - '@aws-sdk/types': 3.862.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-logger@3.876.0': - dependencies: - '@aws-sdk/types': 3.862.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-recursion-detection@3.873.0': - dependencies: - '@aws-sdk/types': 3.862.0 - '@smithy/protocol-http': 5.2.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-sdk-s3@3.883.0': - dependencies: - '@aws-sdk/core': 3.883.0 - '@aws-sdk/types': 3.862.0 - '@aws-sdk/util-arn-parser': 3.873.0 - '@smithy/core': 3.10.0 - '@smithy/node-config-provider': 4.2.0 - '@smithy/protocol-http': 5.2.0 - '@smithy/signature-v4': 5.2.0 - '@smithy/smithy-client': 4.6.0 - '@smithy/types': 4.4.0 - '@smithy/util-config-provider': 4.1.0 - '@smithy/util-middleware': 4.1.0 - '@smithy/util-stream': 4.3.0 - '@smithy/util-utf8': 4.1.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-ssec@3.873.0': - dependencies: - '@aws-sdk/types': 3.862.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-user-agent@3.883.0': - dependencies: - '@aws-sdk/core': 3.883.0 - '@aws-sdk/types': 3.862.0 - '@aws-sdk/util-endpoints': 3.879.0 - '@smithy/core': 3.10.0 - '@smithy/protocol-http': 5.2.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@aws-sdk/nested-clients@3.883.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.883.0 - '@aws-sdk/middleware-host-header': 3.873.0 - '@aws-sdk/middleware-logger': 3.876.0 - '@aws-sdk/middleware-recursion-detection': 3.873.0 - '@aws-sdk/middleware-user-agent': 3.883.0 - '@aws-sdk/region-config-resolver': 3.873.0 - '@aws-sdk/types': 3.862.0 - '@aws-sdk/util-endpoints': 3.879.0 - '@aws-sdk/util-user-agent-browser': 3.873.0 - '@aws-sdk/util-user-agent-node': 3.883.0 - '@smithy/config-resolver': 4.2.0 - '@smithy/core': 3.10.0 - '@smithy/fetch-http-handler': 5.2.0 - '@smithy/hash-node': 4.1.0 - '@smithy/invalid-dependency': 4.1.0 - '@smithy/middleware-content-length': 4.1.0 - '@smithy/middleware-endpoint': 4.2.0 - '@smithy/middleware-retry': 4.2.0 - '@smithy/middleware-serde': 4.1.0 - '@smithy/middleware-stack': 4.1.0 - '@smithy/node-config-provider': 4.2.0 - '@smithy/node-http-handler': 4.2.0 - '@smithy/protocol-http': 5.2.0 - '@smithy/smithy-client': 4.6.0 - '@smithy/types': 4.4.0 - '@smithy/url-parser': 4.1.0 - '@smithy/util-base64': 4.1.0 - '@smithy/util-body-length-browser': 4.1.0 - '@smithy/util-body-length-node': 4.1.0 - '@smithy/util-defaults-mode-browser': 4.1.0 - '@smithy/util-defaults-mode-node': 4.1.0 - '@smithy/util-endpoints': 3.1.0 - '@smithy/util-middleware': 4.1.0 - '@smithy/util-retry': 4.1.0 - '@smithy/util-utf8': 4.1.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/region-config-resolver@3.873.0': - dependencies: - '@aws-sdk/types': 3.862.0 - '@smithy/node-config-provider': 4.2.0 - '@smithy/types': 4.4.0 - '@smithy/util-config-provider': 4.1.0 - '@smithy/util-middleware': 4.1.0 - tslib: 2.8.1 - - '@aws-sdk/signature-v4-multi-region@3.883.0': - dependencies: - '@aws-sdk/middleware-sdk-s3': 3.883.0 - '@aws-sdk/types': 3.862.0 - '@smithy/protocol-http': 5.2.0 - '@smithy/signature-v4': 5.2.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@aws-sdk/token-providers@3.883.0': - dependencies: - '@aws-sdk/core': 3.883.0 - '@aws-sdk/nested-clients': 3.883.0 - '@aws-sdk/types': 3.862.0 - '@smithy/property-provider': 4.1.0 - '@smithy/shared-ini-file-loader': 4.1.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/types@3.862.0': - dependencies: - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@aws-sdk/util-arn-parser@3.873.0': - dependencies: - tslib: 2.8.1 - - '@aws-sdk/util-endpoints@3.879.0': - dependencies: - '@aws-sdk/types': 3.862.0 - '@smithy/types': 4.4.0 - '@smithy/url-parser': 4.1.0 - '@smithy/util-endpoints': 3.1.0 - tslib: 2.8.1 - - '@aws-sdk/util-locate-window@3.873.0': - dependencies: - tslib: 2.8.1 - - '@aws-sdk/util-user-agent-browser@3.873.0': - dependencies: - '@aws-sdk/types': 3.862.0 - '@smithy/types': 4.4.0 - bowser: 2.12.1 - tslib: 2.8.1 - - '@aws-sdk/util-user-agent-node@3.883.0': - dependencies: - '@aws-sdk/middleware-user-agent': 3.883.0 - '@aws-sdk/types': 3.862.0 - '@smithy/node-config-provider': 4.2.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@aws-sdk/xml-builder@3.873.0': - dependencies: - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@cspotcode/source-map-support@0.8.1': - dependencies: - '@jridgewell/trace-mapping': 0.3.9 - - '@inquirer/checkbox@4.2.2(@types/node@18.19.124)': - dependencies: - '@inquirer/core': 10.2.0(@types/node@18.19.124) - '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@18.19.124) - ansi-escapes: 4.3.2 - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 18.19.124 - - '@inquirer/confirm@3.2.0': - dependencies: - '@inquirer/core': 9.2.1 - '@inquirer/type': 1.5.5 - - '@inquirer/confirm@5.1.16(@types/node@18.19.124)': - dependencies: - '@inquirer/core': 10.2.0(@types/node@18.19.124) - '@inquirer/type': 3.0.8(@types/node@18.19.124) - optionalDependencies: - '@types/node': 18.19.124 - - '@inquirer/core@10.2.0(@types/node@18.19.124)': - dependencies: - '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@18.19.124) - ansi-escapes: 4.3.2 - cli-width: 4.1.0 - mute-stream: 2.0.0 - signal-exit: 4.1.0 - wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 18.19.124 - - '@inquirer/core@9.2.1': - dependencies: - '@inquirer/figures': 1.0.13 - '@inquirer/type': 2.0.0 - '@types/mute-stream': 0.0.4 - '@types/node': 22.18.1 - '@types/wrap-ansi': 3.0.0 - ansi-escapes: 4.3.2 - cli-width: 4.1.0 - mute-stream: 1.0.0 - signal-exit: 4.1.0 - strip-ansi: 6.0.1 - wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.3 - - '@inquirer/editor@4.2.18(@types/node@18.19.124)': - dependencies: - '@inquirer/core': 10.2.0(@types/node@18.19.124) - '@inquirer/external-editor': 1.0.1(@types/node@18.19.124) - '@inquirer/type': 3.0.8(@types/node@18.19.124) - optionalDependencies: - '@types/node': 18.19.124 - - '@inquirer/expand@4.0.18(@types/node@18.19.124)': - dependencies: - '@inquirer/core': 10.2.0(@types/node@18.19.124) - '@inquirer/type': 3.0.8(@types/node@18.19.124) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 18.19.124 - - '@inquirer/external-editor@1.0.1(@types/node@18.19.124)': - dependencies: - chardet: 2.1.0 - iconv-lite: 0.6.3 - optionalDependencies: - '@types/node': 18.19.124 - - '@inquirer/figures@1.0.13': {} - - '@inquirer/input@2.3.0': - dependencies: - '@inquirer/core': 9.2.1 - '@inquirer/type': 1.5.5 - - '@inquirer/input@4.2.2(@types/node@18.19.124)': - dependencies: - '@inquirer/core': 10.2.0(@types/node@18.19.124) - '@inquirer/type': 3.0.8(@types/node@18.19.124) - optionalDependencies: - '@types/node': 18.19.124 - - '@inquirer/number@3.0.18(@types/node@18.19.124)': - dependencies: - '@inquirer/core': 10.2.0(@types/node@18.19.124) - '@inquirer/type': 3.0.8(@types/node@18.19.124) - optionalDependencies: - '@types/node': 18.19.124 - - '@inquirer/password@4.0.18(@types/node@18.19.124)': - dependencies: - '@inquirer/core': 10.2.0(@types/node@18.19.124) - '@inquirer/type': 3.0.8(@types/node@18.19.124) - ansi-escapes: 4.3.2 - optionalDependencies: - '@types/node': 18.19.124 - - '@inquirer/prompts@7.8.4(@types/node@18.19.124)': - dependencies: - '@inquirer/checkbox': 4.2.2(@types/node@18.19.124) - '@inquirer/confirm': 5.1.16(@types/node@18.19.124) - '@inquirer/editor': 4.2.18(@types/node@18.19.124) - '@inquirer/expand': 4.0.18(@types/node@18.19.124) - '@inquirer/input': 4.2.2(@types/node@18.19.124) - '@inquirer/number': 3.0.18(@types/node@18.19.124) - '@inquirer/password': 4.0.18(@types/node@18.19.124) - '@inquirer/rawlist': 4.1.6(@types/node@18.19.124) - '@inquirer/search': 3.1.1(@types/node@18.19.124) - '@inquirer/select': 4.3.2(@types/node@18.19.124) - optionalDependencies: - '@types/node': 18.19.124 - - '@inquirer/rawlist@4.1.6(@types/node@18.19.124)': - dependencies: - '@inquirer/core': 10.2.0(@types/node@18.19.124) - '@inquirer/type': 3.0.8(@types/node@18.19.124) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 18.19.124 - - '@inquirer/search@3.1.1(@types/node@18.19.124)': - dependencies: - '@inquirer/core': 10.2.0(@types/node@18.19.124) - '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@18.19.124) - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 18.19.124 - - '@inquirer/select@2.5.0': - dependencies: - '@inquirer/core': 9.2.1 - '@inquirer/figures': 1.0.13 - '@inquirer/type': 1.5.5 - ansi-escapes: 4.3.2 - yoctocolors-cjs: 2.1.3 - - '@inquirer/select@4.3.2(@types/node@18.19.124)': - dependencies: - '@inquirer/core': 10.2.0(@types/node@18.19.124) - '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8(@types/node@18.19.124) - ansi-escapes: 4.3.2 - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 18.19.124 - - '@inquirer/type@1.5.5': - dependencies: - mute-stream: 1.0.0 - - '@inquirer/type@2.0.0': - dependencies: - mute-stream: 1.0.0 - - '@inquirer/type@3.0.8(@types/node@18.19.124)': - optionalDependencies: - '@types/node': 18.19.124 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.9': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.19.1 - - '@oclif/core@4.5.3': - dependencies: - ansi-escapes: 4.3.2 - ansis: 3.17.0 - clean-stack: 3.0.1 - cli-spinners: 2.9.2 - debug: 4.4.1(supports-color@8.1.1) - ejs: 3.1.10 - get-package-type: 0.1.0 - indent-string: 4.0.0 - is-wsl: 2.2.0 - lilconfig: 3.1.3 - minimatch: 9.0.5 - semver: 7.7.2 - string-width: 4.2.3 - supports-color: 8.1.1 - tinyglobby: 0.2.15 - widest-line: 3.1.0 - wordwrap: 1.0.0 - wrap-ansi: 7.0.0 - - '@oclif/plugin-help@6.2.32': - dependencies: - '@oclif/core': 4.5.3 - - '@oclif/plugin-not-found@3.2.67(@types/node@18.19.124)': - dependencies: - '@inquirer/prompts': 7.8.4(@types/node@18.19.124) - '@oclif/core': 4.5.3 - ansis: 3.17.0 - fast-levenshtein: 3.0.0 - transitivePeerDependencies: - - '@types/node' - - '@oclif/plugin-warn-if-update-available@3.1.46': - dependencies: - '@oclif/core': 4.5.3 - ansis: 3.17.0 - debug: 4.4.1(supports-color@8.1.1) - http-call: 5.3.0 - lodash: 4.17.21 - registry-auth-token: 5.1.0 - transitivePeerDependencies: - - supports-color - - '@oclif/prettier-config@0.2.1': {} - - '@pnpm/config.env-replace@1.1.0': {} - - '@pnpm/network.ca-file@1.0.2': - dependencies: - graceful-fs: 4.2.10 - - '@pnpm/npm-conf@2.3.1': - dependencies: - '@pnpm/config.env-replace': 1.1.0 - '@pnpm/network.ca-file': 1.0.2 - config-chain: 1.1.13 - - '@sindresorhus/is@5.6.0': {} - - '@smithy/abort-controller@4.1.0': - dependencies: - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@smithy/chunked-blob-reader-native@4.1.0': - dependencies: - '@smithy/util-base64': 4.1.0 - tslib: 2.8.1 - - '@smithy/chunked-blob-reader@5.1.0': - dependencies: - tslib: 2.8.1 - - '@smithy/config-resolver@4.2.0': - dependencies: - '@smithy/node-config-provider': 4.2.0 - '@smithy/types': 4.4.0 - '@smithy/util-config-provider': 4.1.0 - '@smithy/util-middleware': 4.1.0 - tslib: 2.8.1 - - '@smithy/core@3.10.0': - dependencies: - '@smithy/middleware-serde': 4.1.0 - '@smithy/protocol-http': 5.2.0 - '@smithy/types': 4.4.0 - '@smithy/util-base64': 4.1.0 - '@smithy/util-body-length-browser': 4.1.0 - '@smithy/util-middleware': 4.1.0 - '@smithy/util-stream': 4.3.0 - '@smithy/util-utf8': 4.1.0 - '@types/uuid': 9.0.8 - tslib: 2.8.1 - uuid: 9.0.1 - - '@smithy/credential-provider-imds@4.1.0': - dependencies: - '@smithy/node-config-provider': 4.2.0 - '@smithy/property-provider': 4.1.0 - '@smithy/types': 4.4.0 - '@smithy/url-parser': 4.1.0 - tslib: 2.8.1 - - '@smithy/eventstream-codec@4.1.0': - dependencies: - '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.4.0 - '@smithy/util-hex-encoding': 4.1.0 - tslib: 2.8.1 - - '@smithy/eventstream-serde-browser@4.1.0': - dependencies: - '@smithy/eventstream-serde-universal': 4.1.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@smithy/eventstream-serde-config-resolver@4.2.0': - dependencies: - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@smithy/eventstream-serde-node@4.1.0': - dependencies: - '@smithy/eventstream-serde-universal': 4.1.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@smithy/eventstream-serde-universal@4.1.0': - dependencies: - '@smithy/eventstream-codec': 4.1.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@smithy/fetch-http-handler@5.2.0': - dependencies: - '@smithy/protocol-http': 5.2.0 - '@smithy/querystring-builder': 4.1.0 - '@smithy/types': 4.4.0 - '@smithy/util-base64': 4.1.0 - tslib: 2.8.1 - - '@smithy/hash-blob-browser@4.1.0': - dependencies: - '@smithy/chunked-blob-reader': 5.1.0 - '@smithy/chunked-blob-reader-native': 4.1.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@smithy/hash-node@4.1.0': - dependencies: - '@smithy/types': 4.4.0 - '@smithy/util-buffer-from': 4.1.0 - '@smithy/util-utf8': 4.1.0 - tslib: 2.8.1 - - '@smithy/hash-stream-node@4.1.0': - dependencies: - '@smithy/types': 4.4.0 - '@smithy/util-utf8': 4.1.0 - tslib: 2.8.1 - - '@smithy/invalid-dependency@4.1.0': - dependencies: - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@smithy/is-array-buffer@2.2.0': - dependencies: - tslib: 2.8.1 - - '@smithy/is-array-buffer@4.1.0': - dependencies: - tslib: 2.8.1 - - '@smithy/md5-js@4.1.0': - dependencies: - '@smithy/types': 4.4.0 - '@smithy/util-utf8': 4.1.0 - tslib: 2.8.1 - - '@smithy/middleware-content-length@4.1.0': - dependencies: - '@smithy/protocol-http': 5.2.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@smithy/middleware-endpoint@4.2.0': - dependencies: - '@smithy/core': 3.10.0 - '@smithy/middleware-serde': 4.1.0 - '@smithy/node-config-provider': 4.2.0 - '@smithy/shared-ini-file-loader': 4.1.0 - '@smithy/types': 4.4.0 - '@smithy/url-parser': 4.1.0 - '@smithy/util-middleware': 4.1.0 - tslib: 2.8.1 - - '@smithy/middleware-retry@4.2.0': - dependencies: - '@smithy/node-config-provider': 4.2.0 - '@smithy/protocol-http': 5.2.0 - '@smithy/service-error-classification': 4.1.0 - '@smithy/smithy-client': 4.6.0 - '@smithy/types': 4.4.0 - '@smithy/util-middleware': 4.1.0 - '@smithy/util-retry': 4.1.0 - '@types/uuid': 9.0.8 - tslib: 2.8.1 - uuid: 9.0.1 - - '@smithy/middleware-serde@4.1.0': - dependencies: - '@smithy/protocol-http': 5.2.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@smithy/middleware-stack@4.1.0': - dependencies: - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@smithy/node-config-provider@4.2.0': - dependencies: - '@smithy/property-provider': 4.1.0 - '@smithy/shared-ini-file-loader': 4.1.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@smithy/node-http-handler@4.2.0': - dependencies: - '@smithy/abort-controller': 4.1.0 - '@smithy/protocol-http': 5.2.0 - '@smithy/querystring-builder': 4.1.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@smithy/property-provider@4.1.0': - dependencies: - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@smithy/protocol-http@5.2.0': - dependencies: - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@smithy/querystring-builder@4.1.0': - dependencies: - '@smithy/types': 4.4.0 - '@smithy/util-uri-escape': 4.1.0 - tslib: 2.8.1 - - '@smithy/querystring-parser@4.1.0': - dependencies: - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@smithy/service-error-classification@4.1.0': - dependencies: - '@smithy/types': 4.4.0 - - '@smithy/shared-ini-file-loader@4.1.0': - dependencies: - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@smithy/signature-v4@5.2.0': - dependencies: - '@smithy/is-array-buffer': 4.1.0 - '@smithy/protocol-http': 5.2.0 - '@smithy/types': 4.4.0 - '@smithy/util-hex-encoding': 4.1.0 - '@smithy/util-middleware': 4.1.0 - '@smithy/util-uri-escape': 4.1.0 - '@smithy/util-utf8': 4.1.0 - tslib: 2.8.1 - - '@smithy/smithy-client@4.6.0': - dependencies: - '@smithy/core': 3.10.0 - '@smithy/middleware-endpoint': 4.2.0 - '@smithy/middleware-stack': 4.1.0 - '@smithy/protocol-http': 5.2.0 - '@smithy/types': 4.4.0 - '@smithy/util-stream': 4.3.0 - tslib: 2.8.1 - - '@smithy/types@4.4.0': - dependencies: - tslib: 2.8.1 - - '@smithy/url-parser@4.1.0': - dependencies: - '@smithy/querystring-parser': 4.1.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@smithy/util-base64@4.1.0': - dependencies: - '@smithy/util-buffer-from': 4.1.0 - '@smithy/util-utf8': 4.1.0 - tslib: 2.8.1 - - '@smithy/util-body-length-browser@4.1.0': - dependencies: - tslib: 2.8.1 - - '@smithy/util-body-length-node@4.1.0': - dependencies: - tslib: 2.8.1 - - '@smithy/util-buffer-from@2.2.0': - dependencies: - '@smithy/is-array-buffer': 2.2.0 - tslib: 2.8.1 - - '@smithy/util-buffer-from@4.1.0': - dependencies: - '@smithy/is-array-buffer': 4.1.0 - tslib: 2.8.1 - - '@smithy/util-config-provider@4.1.0': - dependencies: - tslib: 2.8.1 - - '@smithy/util-defaults-mode-browser@4.1.0': - dependencies: - '@smithy/property-provider': 4.1.0 - '@smithy/smithy-client': 4.6.0 - '@smithy/types': 4.4.0 - bowser: 2.12.1 - tslib: 2.8.1 - - '@smithy/util-defaults-mode-node@4.1.0': - dependencies: - '@smithy/config-resolver': 4.2.0 - '@smithy/credential-provider-imds': 4.1.0 - '@smithy/node-config-provider': 4.2.0 - '@smithy/property-provider': 4.1.0 - '@smithy/smithy-client': 4.6.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@smithy/util-endpoints@3.1.0': - dependencies: - '@smithy/node-config-provider': 4.2.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@smithy/util-hex-encoding@4.1.0': - dependencies: - tslib: 2.8.1 - - '@smithy/util-middleware@4.1.0': - dependencies: - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@smithy/util-retry@4.1.0': - dependencies: - '@smithy/service-error-classification': 4.1.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@smithy/util-stream@4.3.0': - dependencies: - '@smithy/fetch-http-handler': 5.2.0 - '@smithy/node-http-handler': 4.2.0 - '@smithy/types': 4.4.0 - '@smithy/util-base64': 4.1.0 - '@smithy/util-buffer-from': 4.1.0 - '@smithy/util-hex-encoding': 4.1.0 - '@smithy/util-utf8': 4.1.0 - tslib: 2.8.1 - - '@smithy/util-uri-escape@4.1.0': - dependencies: - tslib: 2.8.1 - - '@smithy/util-utf8@2.3.0': - dependencies: - '@smithy/util-buffer-from': 2.2.0 - tslib: 2.8.1 - - '@smithy/util-utf8@4.1.0': - dependencies: - '@smithy/util-buffer-from': 4.1.0 - tslib: 2.8.1 - - '@smithy/util-waiter@4.1.0': - dependencies: - '@smithy/abort-controller': 4.1.0 - '@smithy/types': 4.4.0 - tslib: 2.8.1 - - '@szmarczak/http-timer@5.0.1': - dependencies: - defer-to-connect: 2.0.1 - - '@tsconfig/node10@1.0.11': {} - - '@tsconfig/node12@1.0.11': {} - - '@tsconfig/node14@1.0.3': {} - - '@tsconfig/node16@1.0.4': {} - - '@types/http-cache-semantics@4.0.4': {} - - '@types/mute-stream@0.0.4': - dependencies: - '@types/node': 18.19.124 - - '@types/node@18.19.124': - dependencies: - undici-types: 5.26.5 - - '@types/node@22.18.1': - dependencies: - undici-types: 6.21.0 - - '@types/uuid@9.0.8': {} - - '@types/wrap-ansi@3.0.0': {} - - acorn-walk@8.3.4: - dependencies: - acorn: 8.15.0 - - acorn@8.15.0: {} - - ansi-escapes@4.3.2: - dependencies: - type-fest: 0.21.3 - - ansi-regex@5.0.1: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - ansis@3.17.0: {} - - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 - - arg@4.1.3: {} - - array-union@2.1.0: {} - - async-retry@1.3.3: - dependencies: - retry: 0.13.1 - - async@3.2.6: {} - - balanced-match@1.0.2: {} - - binary-extensions@2.3.0: {} - - bowser@2.12.1: {} - - brace-expansion@2.0.2: - dependencies: - balanced-match: 1.0.2 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - cacheable-lookup@7.0.0: {} - - cacheable-request@10.2.14: - dependencies: - '@types/http-cache-semantics': 4.0.4 - get-stream: 6.0.1 - http-cache-semantics: 4.2.0 - keyv: 4.5.4 - mimic-response: 4.0.0 - normalize-url: 8.0.2 - responselike: 3.0.0 - - camel-case@4.1.2: - dependencies: - pascal-case: 3.1.2 - tslib: 2.8.1 - - capital-case@1.0.4: - dependencies: - no-case: 3.0.4 - tslib: 2.8.1 - upper-case-first: 2.0.2 - - change-case@4.1.2: - dependencies: - camel-case: 4.1.2 - capital-case: 1.0.4 - constant-case: 3.0.4 - dot-case: 3.0.4 - header-case: 2.0.4 - no-case: 3.0.4 - param-case: 3.0.4 - pascal-case: 3.1.2 - path-case: 3.0.4 - sentence-case: 3.0.4 - snake-case: 3.0.4 - tslib: 2.8.1 - - chardet@2.1.0: {} - - chokidar@3.6.0: - dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 - - clean-stack@3.0.1: - dependencies: - escape-string-regexp: 4.0.0 - - cli-spinners@2.9.2: {} - - cli-width@4.1.0: {} - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - commander@9.5.0: {} - - config-chain@1.1.13: - dependencies: - ini: 1.3.8 - proto-list: 1.2.4 - - constant-case@3.0.4: - dependencies: - no-case: 3.0.4 - tslib: 2.8.1 - upper-case: 2.0.2 - - content-type@1.0.5: {} - - create-require@1.1.1: {} - - debug@4.4.1(supports-color@8.1.1): - dependencies: - ms: 2.1.3 - optionalDependencies: - supports-color: 8.1.1 - - decompress-response@6.0.0: - dependencies: - mimic-response: 3.1.0 - - defer-to-connect@2.0.1: {} - - detect-indent@7.0.1: {} - - detect-newline@4.0.1: {} - - diff@4.0.2: {} - - dir-glob@3.0.1: - dependencies: - path-type: 4.0.0 - - dot-case@3.0.4: - dependencies: - no-case: 3.0.4 - tslib: 2.8.1 - - ejs@3.1.10: - dependencies: - jake: 10.9.4 - - emoji-regex@8.0.0: {} - - error-ex@1.3.2: - dependencies: - is-arrayish: 0.2.1 - - escape-string-regexp@4.0.0: {} - - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - - fast-levenshtein@3.0.0: - dependencies: - fastest-levenshtein: 1.0.16 - - fast-xml-parser@5.2.5: - dependencies: - strnum: 2.1.1 - - fastest-levenshtein@1.0.16: {} - - fastq@1.19.1: - dependencies: - reusify: 1.1.0 - - fdir@6.5.0(picomatch@4.0.3): - optionalDependencies: - picomatch: 4.0.3 - - filelist@1.0.4: - dependencies: - minimatch: 5.1.6 - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - find-yarn-workspace-root@2.0.0: - dependencies: - micromatch: 4.0.8 - - form-data-encoder@2.1.4: {} - - fs-extra@8.1.0: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 4.0.0 - universalify: 0.1.2 - - fsevents@2.3.3: - optional: true - - get-package-type@0.1.0: {} - - get-stdin@9.0.0: {} - - get-stream@6.0.1: {} - - get-tsconfig@4.10.1: - dependencies: - resolve-pkg-maps: 1.0.0 - - git-hooks-list@3.2.0: {} - - github-slugger@2.0.0: {} - - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - - globby@11.1.0: - dependencies: - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.3.3 - ignore: 5.3.2 - merge2: 1.4.1 - slash: 3.0.0 - - got@13.0.0: - dependencies: - '@sindresorhus/is': 5.6.0 - '@szmarczak/http-timer': 5.0.1 - cacheable-lookup: 7.0.0 - cacheable-request: 10.2.14 - decompress-response: 6.0.0 - form-data-encoder: 2.1.4 - get-stream: 6.0.1 - http2-wrapper: 2.2.1 - lowercase-keys: 3.0.0 - p-cancelable: 3.0.0 - responselike: 3.0.0 - - graceful-fs@4.2.10: {} - - graceful-fs@4.2.11: {} - - has-flag@4.0.0: {} - - header-case@2.0.4: - dependencies: - capital-case: 1.0.4 - tslib: 2.8.1 - - hosted-git-info@7.0.2: - dependencies: - lru-cache: 10.4.3 - - http-cache-semantics@4.2.0: {} - - http-call@5.3.0: - dependencies: - content-type: 1.0.5 - debug: 4.4.1(supports-color@8.1.1) - is-retry-allowed: 1.2.0 - is-stream: 2.0.1 - parse-json: 4.0.0 - tunnel-agent: 0.6.0 - transitivePeerDependencies: - - supports-color - - http2-wrapper@2.2.1: - dependencies: - quick-lru: 5.1.1 - resolve-alpn: 1.2.1 - - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - - ignore@5.3.2: {} - - indent-string@4.0.0: {} - - ini@1.3.8: {} - - is-arrayish@0.2.1: {} - - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.3.0 - - is-docker@2.2.1: {} - - is-extglob@2.1.1: {} - - is-fullwidth-code-point@3.0.0: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-number@7.0.0: {} - - is-plain-obj@4.1.0: {} - - is-retry-allowed@1.2.0: {} - - is-stream@2.0.1: {} - - is-wsl@2.2.0: - dependencies: - is-docker: 2.2.1 - - jake@10.9.4: - dependencies: - async: 3.2.6 - filelist: 1.0.4 - picocolors: 1.1.1 - - json-buffer@3.0.1: {} - - json-parse-better-errors@1.0.2: {} - - json5@2.2.3: {} - - jsonfile@4.0.0: - optionalDependencies: - graceful-fs: 4.2.11 - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - lilconfig@3.1.3: {} - - lodash@4.17.21: {} - - lower-case@2.0.2: - dependencies: - tslib: 2.8.1 - - lowercase-keys@3.0.0: {} - - lru-cache@10.4.3: {} - - make-error@1.3.6: {} - - merge2@1.4.1: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - - mimic-response@3.1.0: {} - - mimic-response@4.0.0: {} - - minimatch@5.1.6: - dependencies: - brace-expansion: 2.0.2 - - minimatch@9.0.5: - dependencies: - brace-expansion: 2.0.2 - - minimist@1.2.8: {} - - ms@2.1.3: {} - - mute-stream@1.0.0: {} - - mute-stream@2.0.0: {} - - mylas@2.1.13: {} - - no-case@3.0.4: - dependencies: - lower-case: 2.0.2 - tslib: 2.8.1 - - normalize-package-data@6.0.2: - dependencies: - hosted-git-info: 7.0.2 - semver: 7.7.2 - validate-npm-package-license: 3.0.4 - - normalize-path@3.0.0: {} - - normalize-url@8.0.2: {} - - oclif@4.22.18(@types/node@18.19.124): - dependencies: - '@aws-sdk/client-cloudfront': 3.883.0 - '@aws-sdk/client-s3': 3.884.0 - '@inquirer/confirm': 3.2.0 - '@inquirer/input': 2.3.0 - '@inquirer/select': 2.5.0 - '@oclif/core': 4.5.3 - '@oclif/plugin-help': 6.2.32 - '@oclif/plugin-not-found': 3.2.67(@types/node@18.19.124) - '@oclif/plugin-warn-if-update-available': 3.1.46 - ansis: 3.17.0 - async-retry: 1.3.3 - change-case: 4.1.2 - debug: 4.4.1(supports-color@8.1.1) - ejs: 3.1.10 - find-yarn-workspace-root: 2.0.0 - fs-extra: 8.1.0 - github-slugger: 2.0.0 - got: 13.0.0 - lodash: 4.17.21 - normalize-package-data: 6.0.2 - semver: 7.7.2 - sort-package-json: 2.15.1 - tiny-jsonc: 1.0.2 - validate-npm-package-name: 5.0.1 - transitivePeerDependencies: - - '@types/node' - - aws-crt - - supports-color - - p-cancelable@3.0.0: {} - - param-case@3.0.4: - dependencies: - dot-case: 3.0.4 - tslib: 2.8.1 - - parse-json@4.0.0: - dependencies: - error-ex: 1.3.2 - json-parse-better-errors: 1.0.2 - - pascal-case@3.1.2: - dependencies: - no-case: 3.0.4 - tslib: 2.8.1 - - path-case@3.0.4: - dependencies: - dot-case: 3.0.4 - tslib: 2.8.1 - - path-type@4.0.0: {} - - picocolors@1.1.1: {} - - picomatch@2.3.1: {} - - picomatch@4.0.3: {} - - plimit-lit@1.6.1: - dependencies: - queue-lit: 1.5.2 - - proto-list@1.2.4: {} - - queue-lit@1.5.2: {} - - queue-microtask@1.2.3: {} - - quick-lru@5.1.1: {} - - readdirp@3.6.0: - dependencies: - picomatch: 2.3.1 - - registry-auth-token@5.1.0: - dependencies: - '@pnpm/npm-conf': 2.3.1 - - resolve-alpn@1.2.1: {} - - resolve-pkg-maps@1.0.0: {} - - responselike@3.0.0: - dependencies: - lowercase-keys: 3.0.0 - - retry@0.13.1: {} - - reusify@1.1.0: {} - - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - - safe-buffer@5.2.1: {} - - safer-buffer@2.1.2: {} - - semver@7.7.2: {} - - sentence-case@3.0.4: - dependencies: - no-case: 3.0.4 - tslib: 2.8.1 - upper-case-first: 2.0.2 - - signal-exit@4.1.0: {} - - slash@3.0.0: {} - - snake-case@3.0.4: - dependencies: - dot-case: 3.0.4 - tslib: 2.8.1 - - sort-object-keys@1.1.3: {} - - sort-package-json@2.15.1: - dependencies: - detect-indent: 7.0.1 - detect-newline: 4.0.1 - get-stdin: 9.0.0 - git-hooks-list: 3.2.0 - is-plain-obj: 4.1.0 - semver: 7.7.2 - sort-object-keys: 1.1.3 - tinyglobby: 0.2.15 - - spdx-correct@3.2.0: - dependencies: - spdx-expression-parse: 3.0.1 - spdx-license-ids: 3.0.22 - - spdx-exceptions@2.5.0: {} - - spdx-expression-parse@3.0.1: - dependencies: - spdx-exceptions: 2.5.0 - spdx-license-ids: 3.0.22 - - spdx-license-ids@3.0.22: {} - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-bom@3.0.0: {} - - strnum@2.1.1: {} - - supports-color@8.1.1: - dependencies: - has-flag: 4.0.0 - - tiny-jsonc@1.0.2: {} - - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - ts-node@10.9.2(@types/node@18.19.124)(typescript@5.9.2): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.11 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 18.19.124 - acorn: 8.15.0 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.9.2 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - - tsc-alias@1.8.16: - dependencies: - chokidar: 3.6.0 - commander: 9.5.0 - get-tsconfig: 4.10.1 - globby: 11.1.0 - mylas: 2.1.13 - normalize-path: 3.0.0 - plimit-lit: 1.6.1 - - tsconfig-paths@4.2.0: - dependencies: - json5: 2.2.3 - minimist: 1.2.8 - strip-bom: 3.0.0 - - tslib@2.8.1: {} - - tunnel-agent@0.6.0: - dependencies: - safe-buffer: 5.2.1 - - type-fest@0.21.3: {} - - typescript@5.9.2: {} - - undici-types@5.26.5: {} - - undici-types@6.21.0: {} - - universalify@0.1.2: {} - - upper-case-first@2.0.2: - dependencies: - tslib: 2.8.1 - - upper-case@2.0.2: - dependencies: - tslib: 2.8.1 - - uuid@9.0.1: {} - - v8-compile-cache-lib@3.0.1: {} - - validate-npm-package-license@3.0.4: - dependencies: - spdx-correct: 3.2.0 - spdx-expression-parse: 3.0.1 - - validate-npm-package-name@5.0.1: {} - - widest-line@3.1.0: - dependencies: - string-width: 4.2.3 - - wordwrap@1.0.0: {} - - wrap-ansi@6.2.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - yn@3.1.1: {} - - yoctocolors-cjs@2.1.3: {} diff --git a/packages/core/skill/SKILL.md b/packages/core/skill/SKILL.md new file mode 100644 index 0000000..21fc401 --- /dev/null +++ b/packages/core/skill/SKILL.md @@ -0,0 +1,349 @@ +--- +name: dcw +description: >- + Use when the user wants to create, build, run, shell into, or manage a Web3 + dev/audit container environment with dcw (@theredguild/devcontainer-wizard). + Triggers on mentions of "dcw", "devcontainer wizard", spinning up a hardened + container for foundry/hardhat/slither/echidna/medusa etc., or driving dcw + non-interactively from an agent. Covers the full command surface, flag + vocabulary, security profiles, and canonical workflows. +--- + +# dcw — container environment wizard + +`dcw` (`@theredguild/devcontainer-wizard`) is an **editor-agnostic, shell-first, AI-native** container +environment wizard for Web3 development and smart-contract auditing. It authors a dev +environment (interactively via an ink TUI, or fully from flags), builds a plain-Debian +image on whatever container engine is available, runs it **hardened**, and manages its +lifecycle. + +It does **not** generate a `devcontainer.json`. It builds a plain image and runs a +hardened container you `shell` into. Hardening options are translated into engine-correct +`run` flags and degrade gracefully when an engine can't honor them. + +Supported engines: Docker, OrbStack (macOS, auto-preferred), Podman (rootless), Lima +(nerdctl), Apple Containers (macOS 15+ arm64, VM-isolated). Run `dcw engines` for live +availability and per-engine hardening trade-offs. + +## Agent rule of thumb + +When driving dcw programmatically (not as an interactive human): + +- Always pass **`--no-input`** so it never prompts, and **`--json`** for structured output. +- Use **`dcw schema`** first to discover the live option vocabulary (languages, frameworks, + tools, profiles, hardening, engines) before composing flags — don't hardcode from memory. +- Add **`--strict`** when hardening must be guaranteed (fail instead of silently dropping). +- Exit codes are deterministic; errors carry a machine `code` field under `--json`. +- The interactive ink wizard only mounts on a real TTY when none of `--json`, + `--no-input`, or `--yes` are set — so the JSON path never touches the TUI. + +## Exit codes and the `--json` error envelope + +Exit codes are a stable contract — branch on them rather than parsing message text: + +| Code | Meaning | Machine `code` | +| --- | --- | --- | +| 0 | Success | — | +| 1 | Generic failure | `E_GENERIC` / `E_INTERNAL` | +| 2 | Usage error: unknown command/flag, bad flag value, missing confirmation | `E_USAGE` / `E_CONFIRM` | +| 3 | No container engine available at all | `E_NO_ENGINE` | +| 4 | Requested engine unsupported on this host | `E_ENGINE_UNSUPPORTED` | +| 5 | Requested engine supported but not running/installed | `E_ENGINE_UNAVAILABLE` | +| 6 | Cancelled by the user | `E_CANCELLED` | +| 7 | `--strict`: hardening dropped or unenforced | `E_STRICT_HARDENING` | +| 8 | Environment / container not found | `E_NOT_FOUND` | +| 9 | Invalid input (bad name, profile, git URL, …) | `E_VALIDATION` | + +Under `--json`, **every** failure — including usage errors — prints a single envelope on +stdout and nothing else, so stdout is always parseable: + +```json +{ "error": { "code": "E_NOT_FOUND", "message": "Environment 'nope' not found." } } +``` + +Human-readable warnings go to **stderr**, never stdout. Parse stdout, log stderr. + +## Command reference + +`[name]` is optional on lifecycle commands and defaults to the sole / `.dcw` environment. + +| Command | Purpose | Key flags | +| --- | --- | --- | +| `dcw create` | Author an environment (wizard or flags) | see below | +| `dcw build [name]` | Build the container image | `--force`, `--platform

` | +| `dcw up [name]` | Build (if needed) + start a hardened container | `--rebuild`, `--workspace

` | +| `dcw shell [name]` | Interactive zsh into the container (lands in `/workspace` as `vscode`) | — | +| `dcw attach [name]` | Attach an SSH-remote editor (Zed, VS Code, Cursor, Antigravity, …) | `--editor `, `--port `, `--print`, `--folder ` | +| `dcw exec [name] -- ` | Run a command in the running container | trailing `-- ` | +| `dcw agent [name]` | Spawn an AI coding agent (claude/codex/opencode) in the container | `-e`/`--env`, `--install` | +| `dcw ls` | List environments + live container status | — | +| `dcw stop [name]` | Stop the running container | — | +| `dcw rm [name]` | Remove the container (optionally the env) | `--purge` (requires `--yes`) | +| `dcw logs [name]` | Show container logs | `-f`/`--follow`, `--tail ` | +| `dcw engines` | Engine availability + hardening trade-offs | — | +| `dcw schema` | JSON schema + full option vocabulary (always JSON) | — | +| `dcw --skill` / `dcw skill` | Print this skill file (SKILL.md) and exit | — | + +### `dcw create` flags + +All selection flags are **repeatable** (pass the flag multiple times): + +- `--name ` — environment name (defaults to the current directory name) +- `--core-lang ` — core languages: `rust`, `python`, `go`, `node` +- `--lang ` — smart-contract languages: `solidity`, `vyper` +- `--framework ` — frameworks: `foundry`, `hardhat`, `ape` +- `--fuzz ` — fuzzing/testing: `echidna`, `medusa`, `halmos`, `ityfuzz`, `aderyn` +- `--sec ` — security tooling: `slither`, `mythril`, `crytic-compile`, `panoramix`, + `slither-lsp`, `napalm-toolbox`, `semgrep`, `slitherin`, `heimdall` +- `--ai-agent ` — AI coding agents to bake in: `claude`, `codex`, `opencode` (all pull Node) +- `--profile ` — named security profile (see below). **Defaults to `development`** + when neither `--profile` nor `--harden` is given; pass `--profile none` to opt out of + hardening entirely. +- `--harden ` — manual hardening key, repeatable, **merged with** `--profile` +- `--git-url ` — clone this git repo into the image. Accepts `https://`, `git://`, + `ssh://` and scp-style `git@host:org/repo.git`. A bare SSH login (`ssh://git@host/o/r`) + is fine, but **embedded credentials are rejected** (`https://user:token@host`, + `ssh://user:pass@host`, and any `https://user@host`), as is percent-encoding and + anything with whitespace or shell metacharacters — the URL is persisted to the manifest + and the image's git remote, so a token there would leak. Use SSH keys or a credential + helper for private repos. Rejection is `E_VALIDATION` (exit 9). +- `--git-branch ` — branch/tag to clone (requires `--git-url`) +- `--[no-]ssh` — bake an SSH server into the image for editor attach (`dcw attach`); **on by default**, use `--no-ssh` to omit +- `--build` — build the image after creating +- `--up` — build **and** start the container after creating +- `--force` — overwrite an existing environment that has the same name + +Note: tools pull their own dependencies (e.g. `foundry` pulls Rust, `slither` pulls +Python), so you don't have to list a core language just to satisfy a tool. + +### `dcw attach` — SSH-remote editors + +Wires up SSH into the environment's container (starting it if needed) and launches an +SSH-remote editor against it — the v2 replacement for the v1 VS Code Dev Containers +workflow. Requires the image to have been created with `--ssh` (the default; `--no-ssh` +environments must be recreated, or rebuilt with ssh enabled, before `dcw attach` works). + +```sh +dcw attach # default env; auto-detects an installed editor +dcw attach my-env --editor zed # pick an editor: zed, vscode, cursor, antigravity, … +dcw attach my-env --print # just print connection details, don't launch an editor +dcw attach my-env --port 2222 # publish a fixed TCP port instead of the default exec proxy +``` + +- Writes a managed `~/.ssh/config` block (host alias `dcw-`) so any SSH-remote + editor, or plain `ssh dcw-`, connects the same way. +- Two connection modes: the default **exec proxy** (no published port) or **port** mode + (`--port `, `--port 0` to auto-allocate) with a listening sshd in the container. + Published ports bind **`127.0.0.1` only** — the container's sshd is never exposed to + the LAN. `--port` is refused outright on a `network-none` environment. +- `--folder ` sets the remote folder to open (default `/workspace`). +- `--workspace ` sets the **host** directory mounted at `/workspace`, used only + when `attach` has to start a stopped container (defaults to the current directory). + +### `dcw ls` — status vocabulary + +Each environment is reconciled against **its own** engine (the one it was built on), so a +mixed-engine setup reports each correctly. `status` is one of: + +| Status | Meaning | +| --- | --- | +| `running` | Container exists and is up | +| `stopped` | Container exists but is not running | +| `never-started` | Environment created, no container ever started | +| `absent` | The environment had a container and the engine says it is gone | +| `unknown` | The engine could not be reached, so its state is genuinely unknown | + +`unknown` is **not** `absent`: don't treat it as "safe to recreate". + +`dcw ls` is deliberately non-fatal — if an engine can't be reached it still exits 0 and +reports `unknown` for that engine's environments, rather than failing with +`E_ENGINE_UNAVAILABLE` the way commands that actually need the engine do. + +### `dcw exec` + +Pass the command after `--`. The env name is optional: + +```sh +dcw exec my-env -- forge --version +dcw exec -- ls -la # uses the sole/.dcw environment +``` + +### `dcw agent` — AI coding agents in the container + +Spawns `claude` (Anthropic Claude Code), `codex` (OpenAI Codex), or `opencode` +(multi-provider) interactively inside the running container. The agent must be baked in +(select it in the wizard or `dcw create --ai-agent `) or added with `--install`. + +```sh +dcw agent claude # default env; opens Claude Code in /workspace +dcw agent codex my-env -- --version # forward args to the agent after `--` +dcw agent opencode --env GITHUB_TOKEN # forward extra host env vars by NAME (inline NAME=value is rejected: secrets must not hit argv) +dcw agent claude --install # npm-install the agent on-demand if missing +``` + +- **API keys** are forwarded from the host automatically: `ANTHROPIC_API_KEY` for claude, + `OPENAI_API_KEY` for codex, both for opencode. If none is set the agent falls back to its + own login flow (a warning is printed). +- **Network:** agents need to reach their provider. `dcw agent` decides whether the + air-gap is real from the flags the container was **actually launched with**, not from + what was requested, and warns differently in each case (fails under `--strict` in all + three): + - *enforced* — the container really is offline; the agent cannot reach its API. + - *dropped* — `network-none` was requested but the engine could not apply it, so the + container **is online**. Credentials you forward are reachable by anything running + inside it. + - *unknown* — dcw has no record of what was applied; treated as online. + + If you are forwarding provider keys into an environment the user believes is + air-gapped, confirm `hardening.dropped` does not contain `network-none` first. +- Equivalent low-level form (no key auto-forwarding): `dcw exec -- claude`. + +## Global flags + +- `--yes`, `-y` — assume yes / accept defaults; do not prompt +- `--no-input` — never prompt; non-zero exit if input is required +- `--engine ` — target engine: `auto` (default), `docker`, `podman`, `orbstack`, + `apple-container`, `lima`. Also settable via `DCW_ENGINE` env var. +- `--strict` — fail if a requested hardening option can't be honored by the engine +- `--json` — machine-readable output + +Two exceptions worth knowing before you script against these: + +- `dcw skill` takes **none** of them. It is a static print and accepts `--json` only + as an ignored no-op. +- `exec`, `shell`, `logs` and `agent` accept the flags above, but `--json` produces + **no JSON payload** on them — they stream the container's output through and + propagate its exit code. Do not parse their stdout as JSON. + +## Security profiles + +Pass a profile with `--profile `. Profiles expand to a set of hardening keys; +`--profile` and any `--harden` flags are merged. + +**`dcw create` is hardened by default.** With neither `--profile` nor `--harden`, it +applies the `development` profile — it does *not* create an unhardened environment. +Use `--profile none` when you explicitly want no hardening. + +| Key | Summary | +| --- | --- | +| `development` | Balanced security for daily development. **Applied by default.** | +| `hardened` | Enhanced security for auditing/research. Packet-crafting tools won't work. | +| `airgapped` | Hardened + no network. Extensions/package managers won't work. | +| `paranoid` *(experimental)* | Max security: air-gapped + read-only, ephemeral. | +| `network-restricted-analysis` *(exp)* | Web/git/package installs, no packet crafting. | +| `ci-like-local-runner` *(exp)* | Mirrors CI locally with immutable FS. Cache writes don't persist. | +| `package-install-session` *(exp)* | Install packages while keeping guardrails. | +| `security-research-controlled-net` *(exp)* | API testing/collectors, no packet crafting. | +| `none` | Explicit opt-out — no hardening at all. Not a profile expansion. | + +Individual hardening keys can also be set directly, e.g. +`--harden drop-caps --harden readonly-os` (keys include `readonly-os`, +`ephemeral-workspace`, `secure-tmp`, `drop-caps`, `no-new-privs`, `apparmor`, +`no-raw-packets`, `secure-dns`, `network-none`, `vscode-security`). Use `dcw schema` +for the authoritative list. + +**AppArmor is not enforced on macOS.** Docker Desktop and OrbStack run containers in a +Linux VM whose daemon reports no AppArmor support, and dcw probes this directly +(`docker info` → `SecurityOptions`) rather than assuming it. + +Note *where* this shows up in JSON: the flag is still passed to the engine, it simply +does nothing — so `apparmor` appears in **`unenforced`**, not in `dropped`. A run that +reports `"dropped": []` can still have unenforced controls. Check both arrays. +`--strict` **fails closed** on either, so on macOS it fails for every built-in profile +(all four request `apparmor`). Never tell a user an environment is AppArmor-protected +on macOS. + +### Hardening behavior + +An option the engine can't honor is **dropped with a warning**; one it accepts but can't +actually enforce is reported as **unenforced**. Under `--strict` either is a hard failure +(exit 7). + +Never assume a requested control was applied — verify it from the JSON: + +- `dcw up --json` → `appliedFlags`, `warnings`, `dropped`, `unenforced`, plus the same + data grouped under `hardening`. +- `dcw create --up --json` → a `hardening` object (absent when `--up` was not passed). +- `dcw attach --json` → a `hardening` object describing the container you attached to. + +```jsonc +"hardening": { + "appliedFlags": ["--cap-drop=ALL", "--security-opt", "apparmor=docker-default"], + "warnings": [{ "level": "caveat", "effect": "apparmor", "message": "…" }], + "dropped": [], // requested, NOT applied + "unenforced": ["apparmor"] // applied, but the engine may not enforce it +} +``` + +`--strict` also applies to commands that enter an **already-running** container +(`exec`, `shell`, `agent`, `attach`): if that container was started with dropped or +unenforced hardening, they refuse with exit 7 rather than handing you a weaker +environment than you asked for. + +## Canonical workflows + +Interactive (human): + +```sh +dcw create # interactive wizard (engine chosen first) +dcw build my-env # build the image +dcw up my-env # start a hardened container +dcw shell my-env # zsh into it (lands in /workspace as the vscode user) +dcw attach my-env # attach an SSH-remote editor (VS Code, Cursor, Zed, …) +dcw ls # list environments + live status +dcw stop my-env +dcw rm my-env --purge --yes # --purge without --yes is refused (E_CONFIRM, exit 2) +``` + +One-shot, non-interactive (agent-friendly): + +```sh +dcw create --no-input --name audit \ + --core-lang rust --framework foundry --sec slither \ + --profile hardened --build --up --json +``` + +Inspect capabilities before composing flags: + +```sh +dcw engines # which engines are available + trade-offs +dcw schema # JSON schema + full option vocabulary +``` + +## Installing / refreshing this skill + +The installed binary ships the authoritative copy of this file. `dcw --skill` prints it +to stdout (the CLI is the single source of truth, so re-run it after upgrading dcw): + +```sh +mkdir -p ~/.claude/skills/dcw && dcw --skill > ~/.claude/skills/dcw/SKILL.md # Claude Code (user) +mkdir -p .claude/skills/dcw && dcw --skill > .claude/skills/dcw/SKILL.md # Claude Code (project) +mkdir -p .agents/skills/dcw && dcw --skill > .agents/skills/dcw/SKILL.md # Codex / other agents +``` + +## State locations + +Environments live under XDG paths: + +- `~/.config/dcw/environments/.json` — manifest (spec + resolved tools + image/container state) +- `~/.local/state/dcw//Containerfile` — generated build file + +## Running from source (development) + +Global install: + +```sh +npm install -g @theredguild/devcontainer-wizard # or: pnpm add -g @theredguild/devcontainer-wizard +``` + +From the repo (runs source via tsx — pass CLI args directly, **no** `--` +separator; pnpm forwards a literal `--` to oclif and it errors): + +```sh +pnpm --filter @theredguild/devcontainer-wizard dev --help +pnpm --filter @theredguild/devcontainer-wizard dev schema +pnpm --filter @theredguild/devcontainer-wizard dev engines +pnpm --filter @theredguild/devcontainer-wizard build # tsc → dist +pnpm --filter @theredguild/devcontainer-wizard test # unit + wizard tests (no daemon) +pnpm --filter @theredguild/devcontainer-wizard test:e2e # gated: drives a real container engine +``` diff --git a/packages/core/src/base-command.ts b/packages/core/src/base-command.ts new file mode 100644 index 0000000..592abc2 --- /dev/null +++ b/packages/core/src/base-command.ts @@ -0,0 +1,79 @@ +import { Command, Errors, Flags } from '@oclif/core' +import { DcwError, ExitCode } from './errors.js' + +/** + * A no-op `--json` flag for streaming pass-through commands (exec/shell/logs/agent) + * which set `enableJsonFlag = false`. Accepting and ignoring `--json` keeps agents + * that "always pass --json" working instead of hard-erroring on an unknown flag. + */ +export const ignoredJsonFlag = Flags.boolean({ + description: 'Accepted for compatibility; this streaming command has no JSON output.', + hidden: true, +}) + +/** + * Shared base for every dcw command. + * + * Provides the global, AI-native flags: `--json` (machine output), `--yes` / + * `--no-input` (never prompt), `--engine` (target a specific container engine) + * and `--strict` (treat dropped hardening as a hard error). + */ +export abstract class BaseCommand extends Command { + static enableJsonFlag = true + + static baseFlags = { + yes: Flags.boolean({ + char: 'y', + description: 'Assume yes / accept defaults; do not prompt interactively.', + default: false, + }), + 'no-input': Flags.boolean({ + description: 'Never prompt; fail with a non-zero exit if input is required.', + default: false, + }), + engine: Flags.string({ + description: 'Container engine to target (default: auto-detect).', + options: ['auto', 'docker', 'podman', 'orbstack', 'apple-container', 'lima'], + env: 'DCW_ENGINE', + }), + strict: Flags.boolean({ + description: 'Fail if a requested hardening option cannot be honored by the chosen engine.', + default: false, + }), + } + + /** Map domain errors to their deterministic exit codes + machine code. */ + protected async catch(err: Error & { exitCode?: number }): Promise { + if (err instanceof DcwError) { + // Under --json, emit a machine-readable envelope on stdout (with the + // documented `code` field) instead of human text on stderr. + if (this.jsonEnabled()) { + this.logJson({ error: { code: err.code, message: err.message } }) + this.exit(err.exitCode) + } + this.error(err.message, { exit: err.exitCode, code: err.code }) + } + // oclif's own parse/usage errors (unknown flag, bad --engine value, …) must + // honor the --json contract too. Left to oclif, `--json` serializes the whole + // CLIError — ~120 kB of internal state including the resolved config, home + // directory, shell and plugin list — to stdout with no `code`/`message` and + // exit 1. Emit the documented envelope with the parse error's own exit code. + // ExitError (a deliberate this.exit()) is not an error and must pass through. + if (this.jsonEnabled() && err instanceof Errors.CLIError && !(err instanceof Errors.ExitError)) { + const exit = typeof err.oclif?.exit === 'number' ? err.oclif.exit : ExitCode.UsageError + this.logJson({ error: { code: exit === ExitCode.UsageError ? 'E_USAGE' : 'E_CLI', message: err.message } }) + this.exit(exit) + } + + // Untyped failures (fs errors, bugs) must still honor the --json contract: + // a single {error:{code,message}} envelope, never a raw Node error object. + if (this.jsonEnabled() && !(err instanceof Errors.CLIError)) { + const sys = (err as NodeJS.ErrnoException).code + this.logJson({ + error: { code: 'E_INTERNAL', message: sys ? `${err.message} (${sys})` : err.message }, + }) + this.exit(ExitCode.GenericError) + } + return super.catch(err) + } +} diff --git a/packages/core/src/cli/context.ts b/packages/core/src/cli/context.ts new file mode 100644 index 0000000..b30ef53 --- /dev/null +++ b/packages/core/src/cli/context.ts @@ -0,0 +1,133 @@ +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import { NotFoundError, StrictHardeningError, ValidationError } from '../errors.js' +import { isValidEnvName } from '../util/slug.js' +import { detectHost, type HostInfo } from '../engine/host.js' +import { resolveEngine } from '../engine/resolver.js' +import type { DetectResult, EngineCapabilities, EngineDriver, EngineName } from '../engine/types.js' +import type { EnvManifest } from '../state/manifest.js' +import { listManifests, loadManifest } from '../state/store.js' + +/** + * Reject any environment name that isn't a safe, canonical identifier. User + * input and `.dcw` markers are otherwise spliced verbatim into filesystem + * paths (manifests, state dirs), so an unchecked `../…` traverses out of tree. + */ +function assertValidEnvName(name: string): string { + if (!isValidEnvName(name)) { + throw new ValidationError(`Invalid environment name '${name}'. Names must be lowercase slugs (a-z, 0-9, '.', '_', '-').`) + } + return name +} + +/** Resolve the target environment name: positional → `.dcw` file → sole env. */ +export async function resolveEnvName(positional?: string): Promise { + if (positional) return assertValidEnvName(positional) + + try { + const dot = (await fs.readFile(path.join(process.cwd(), '.dcw'), 'utf8')).trim() + if (dot) return assertValidEnvName(dot) + } catch (err) { + if (err instanceof ValidationError) throw err + // no .dcw file + } + + const all = await listManifests() + if (all.length === 1) return all[0]!.name + if (all.length === 0) { + throw new NotFoundError('No environments found. Create one with `dcw create`.') + } + throw new NotFoundError(`Multiple environments exist; specify one of: ${all.map((m) => m.name).join(', ')}.`) +} + +export async function requireManifest(name: string): Promise { + const manifest = await loadManifest(name) + if (!manifest) throw new NotFoundError(`Environment '${name}' not found.`) + return manifest +} + +export interface EngineContext { + driver: EngineDriver + engineName: EngineName + capabilities: EngineCapabilities + host: HostInfo + detect: DetectResult +} + +/** + * Resolve the engine for a command: an explicit `--engine` flag wins, then the + * environment's saved engine preference, then auto-detection. + */ +export async function resolveEngineFor(opts: { + requested?: string + manifestEngine?: string | null +}): Promise { + const host = await detectHost() + const flagEngine = opts.requested && opts.requested !== 'auto' ? opts.requested : undefined + const savedEngine = opts.manifestEngine && opts.manifestEngine !== 'auto' ? opts.manifestEngine : undefined + const requested = flagEngine ?? savedEngine + const { driver, detect } = await resolveEngine({ requested, host }) + return { driver, engineName: driver.name, capabilities: driver.capabilities, host, detect } +} + +export function nowIso(): string { + return new Date().toISOString() +} + +/** Exec a command (default zsh) into an environment's running container. Returns the exit code. */ +/** + * Under `--strict`, refuse to enter a container whose hardening the engine could + * not deliver. + * + * `--strict` promises to "fail if a requested hardening option cannot be honored". + * `up`/`create` enforce that when they START a container, but `exec`, `shell` and + * `attach` reach an ALREADY-running one, where the check was skipped entirely — so + * `dcw shell --strict` would drop the user into a container that silently lost its + * air-gap or capability drops. The container's manifest records what was actually + * dropped at start time, so this needs no engine round-trip. + * + * Both dropped AND unenforced controls block, matching `enforceStrict`: a flag that + * was emitted but does nothing (AppArmor on a Docker VM with no LSM) is exactly the + * silent failure `--strict` exists to surface. + */ +export function assertStrictContainer(manifest: EnvManifest, strict?: boolean): void { + if (!strict) return + const dropped = manifest.container?.droppedHardening ?? [] + const unenforced = manifest.container?.unenforcedHardening ?? [] + const blocking = [...dropped, ...unenforced] + if (blocking.length === 0) return + throw new StrictHardeningError( + `--strict: container '${manifest.name}' is running without hardening the engine could not honor: ` + + `${blocking.join(', ')}. Recreate it on an engine that supports these, or drop --strict.`, + ) +} + +export async function execInto(opts: { + name: string + cmd: string[] + requested?: string + env?: Record + /** Fail rather than enter a container with dropped hardening. */ + strict?: boolean +}): Promise { + const manifest = await requireManifest(opts.name) + assertStrictContainer(manifest, opts.strict) + if (!manifest.container?.name) { + throw new NotFoundError(`Environment '${opts.name}' has no container. Start it with \`dcw up ${opts.name}\`.`) + } + const { driver } = await resolveEngineFor({ + requested: opts.requested, + manifestEngine: manifest.engine ?? manifest.spec.engine, + }) + const target = manifest.container.id ?? manifest.container.name + // Only allocate a TTY / attach stdin when we actually have a terminal, so + // `dcw exec env -- cmd` works non-interactively (agents, CI, pipes). + const tty = Boolean(process.stdin.isTTY && process.stdout.isTTY) + return driver.exec({ + container: target, + cmd: opts.cmd.length > 0 ? opts.cmd : ['zsh'], + interactive: tty, + tty, + env: opts.env, + }) +} diff --git a/packages/core/src/commands/agent.ts b/packages/core/src/commands/agent.ts new file mode 100644 index 0000000..ea735a7 --- /dev/null +++ b/packages/core/src/commands/agent.ts @@ -0,0 +1,220 @@ +import { Args, Flags } from '@oclif/core' +import { BaseCommand, ignoredJsonFlag } from '../base-command.js' +import { assertStrictContainer, execInto, requireManifest, resolveEngineFor, resolveEnvName } from '../cli/context.js' +import { DcwError, NotFoundError, StrictHardeningError, ValidationError } from '../errors.js' +import type { EnvManifest } from '../state/manifest.js' +import { listManifests } from '../state/store.js' + +/** How an environment's requested air-gap actually turned out at run time. */ +export type AirgapState = 'none' | 'enforced' | 'dropped' | 'unknown' + +/** + * Decide whether the container this agent is about to run in is really air-gapped. + * + * `spec.hardening` records what was *requested*; the container record says what the + * engine actually delivered. Reasoning from the request alone inverts the truth on + * engines that cannot enforce `--network=none` (e.g. Apple Containers): dcw would + * tell the user the agent has no network — and then forward their provider API key + * into a container that is, in fact, online. + * + * Absence of evidence is NOT evidence of enforcement: a container with no recorded + * flags returns 'unknown', which callers must treat as unsafe. Only a positive + * `--network=none` in the flags the container was actually launched with counts. + */ +export function assessAirgap(manifest: EnvManifest): AirgapState { + if (!manifest.spec.hardening.includes('network-none')) return 'none' + + const container = manifest.container + if (!container) return 'unknown' // never started — nothing was applied to anything + if ((container.droppedHardening ?? []).includes('network-none')) return 'dropped' + + const flags = container.appliedFlags + if (!flags || flags.length === 0) return 'unknown' // no record of what was applied + return flags.some((f) => f === '--network=none' || f.startsWith('--network=none')) + ? 'enforced' + : 'dropped' +} + +/** AI coding agents that can be baked in (see catalog `aiAgents`) and spawned. */ +const AGENTS = { + claude: { bin: 'claude', pkg: '@anthropic-ai/claude-code', keys: ['ANTHROPIC_API_KEY'] }, + codex: { bin: 'codex', pkg: '@openai/codex', keys: ['OPENAI_API_KEY'] }, + opencode: { bin: 'opencode', pkg: 'opencode-ai', keys: ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY'] }, +} as const + +type AgentType = keyof typeof AGENTS +const AGENT_TYPES = Object.keys(AGENTS) as AgentType[] + +/** + * Resolve `--env` entries to forwarded variables. Only bare NAMEs are accepted: + * an inline `NAME=value` would sit in dcw's own argv (world-readable via + * `ps`/procfs on most hosts) for the whole interactive agent session, so it is + * rejected with a message that never echoes the value. + */ +export function parseEnvForwards( + entries: readonly string[], + hostEnv: Readonly>, +): Record { + const env: Record = {} + for (const entry of entries) { + const eq = entry.indexOf('=') + if (eq >= 0) { + const varName = entry.slice(0, eq) || '' + throw new ValidationError( + `--env ${varName}=… is not supported: values on the command line leak via the process table. ` + + `Export ${varName} on the host and pass --env ${varName} instead.`, + ) + } + if (!entry) throw new ValidationError('Invalid --env entry: empty variable name.') + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(entry)) { + throw new ValidationError(`Invalid --env entry '${entry}': not a valid environment variable name.`) + } + const v = hostEnv[entry] + if (v !== undefined) env[entry] = v + } + return env +} + +export default class Agent extends BaseCommand { + static description = "Spawn an AI coding agent (claude, codex, opencode) inside an environment's running container." + static examples = [ + '<%= config.bin %> agent claude', + '<%= config.bin %> agent codex my-env', + '<%= config.bin %> agent opencode -- run "review this contract"', + '<%= config.bin %> agent claude my-env --env GITHUB_TOKEN', + ] + + // Streaming pass-through; the agent's exit code is our exit code (no JSON). + static enableJsonFlag = false + + // Allow arbitrary trailing args to be forwarded to the agent CLI. + static strict = false + + static args = { + type: Args.string({ + description: 'Agent to spawn.', + required: true, + options: AGENT_TYPES as unknown as string[], + }), + name: Args.string({ description: 'Environment name (defaults to the sole/.dcw environment).' }), + } + + static flags = { + env: Flags.string({ + char: 'e', + multiple: true, + description: 'Forward an extra env var into the container by NAME (value read from the host environment; inline NAME=value is rejected so secrets never appear in argv).', + }), + install: Flags.boolean({ + description: 'Install the agent CLI on-demand if missing (npm install -g; needs Node + network).', + default: false, + }), + json: ignoredJsonFlag, + } + + async run(): Promise { + const { argv, flags } = await this.parse(Agent) + const tokens = argv as string[] + + const type = tokens[0] as AgentType + if (!AGENT_TYPES.includes(type)) { + throw new ValidationError(`Unknown agent '${tokens[0]}'. Choose one of: ${AGENT_TYPES.join(', ')}.`) + } + const agent = AGENTS[type] + + // After the agent type: an optional env name, then trailing args for the CLI. + // The first token is only the env name when it actually names an existing + // environment; otherwise it (and the rest) are arguments forwarded to the agent. + const rest = tokens.slice(1) + const first = rest[0] + const namesEnv = first !== undefined && !first.startsWith('-') && (await listManifests()).some((m) => m.name === first) + const name = await resolveEnvName(namesEnv ? first : undefined) + const trailing = namesEnv ? rest.slice(1) : rest + + const manifest = await requireManifest(name) + + // Agents need to reach their provider API. Warn (or fail under --strict) when + // the environment is air-gapped — and distinguish an air-gap that actually + // holds from one the engine silently dropped, because we are about to forward + // provider credentials into this container. + // Refuse a weakened container up front: everything below (installing the agent + // CLI, forwarding credentials) mutates or exposes it, so --strict must be + // decided before any of that happens. + assertStrictContainer(manifest, flags.strict) + + const airgap = assessAirgap(manifest) + if (airgap === 'enforced') { + const msg = `Environment '${name}' is hardened with network-none; ${agent.bin} cannot reach its API.` + if (flags.strict) throw new StrictHardeningError(msg) + this.warn(msg) + } else if (airgap === 'dropped' || airgap === 'unknown') { + const detail = + airgap === 'dropped' + ? 'but the engine could not enforce it — this container HAS network access' + : 'but dcw has no record that it was applied — assume this container HAS network access' + const msg = + `Environment '${name}' requested network-none, ${detail}. Credentials forwarded to ` + + `${agent.bin} are reachable by anything running inside it. Re-create on an engine that ` + + 'supports network-none, or re-run with --strict to refuse.' + if (flags.strict) throw new StrictHardeningError(msg) + this.warn(msg) + } + + // Forward the agent's provider key(s) from the host, plus any extra --env vars. + const env: Record = {} + for (const key of agent.keys) { + const v = process.env[key] + if (v !== undefined) env[key] = v + } + Object.assign(env, parseEnvForwards(flags.env ?? [], process.env)) + if (!agent.keys.some((k) => k in env)) { + this.warn( + `No API key found on the host (${agent.keys.join(' or ')}); ${agent.bin} will rely on its own login.`, + ) + } + + // Confirm the binary is present; offer a fix or do an on-demand install. + const { driver } = await resolveEngineFor({ + requested: flags.engine, + manifestEngine: manifest.engine ?? manifest.spec.engine, + }) + const target = manifest.container?.id ?? manifest.container?.name + if (!target) { + throw new NotFoundError(`Environment '${name}' has no container. Start it with \`dcw up ${name}\`.`) + } + + // Tools (node, the agent bin, …) live on the interactive-zsh PATH (nvm + + // ~/.zshrc), not the bare exec PATH — so everything runs through `zsh -ic`. + const present = await driver.execCapture({ + container: target, + cmd: ['zsh', '-ic', `command -v ${agent.bin}`], + interactive: false, + tty: false, + }) + if (present.code !== 0) { + if (flags.install) { + this.log(`Installing ${agent.bin} (npm install -g ${agent.pkg})…`) + const res = await driver.execCapture({ + container: target, + cmd: ['zsh', '-ic', `npm install -g ${agent.pkg}`], + interactive: false, + tty: false, + }) + if (res.code !== 0) { + throw new DcwError(`Failed to install ${agent.pkg}: ${res.stderr.trim() || res.stdout.trim()}`) + } + } else { + throw new NotFoundError( + `'${agent.bin}' is not installed in '${name}'. Recreate with \`dcw create --ai-agent ${type}\`, ` + + `or pass --install to add it on-demand.`, + ) + } + } + + // `zsh -ic 'exec bin "$@"' bin `: load the login PATH, then hand the + // TTY to the agent. Args are passed as positionals so they aren't re-split. + const cmd = ['zsh', '-ic', `exec ${agent.bin} "$@"`, agent.bin, ...trailing] + const code = await execInto({ name, cmd, requested: flags.engine, env, strict: flags.strict }) + this.exit(code) + } +} diff --git a/packages/core/src/commands/attach.ts b/packages/core/src/commands/attach.ts new file mode 100644 index 0000000..20dee7e --- /dev/null +++ b/packages/core/src/commands/attach.ts @@ -0,0 +1,271 @@ +import { Args, Flags } from '@oclif/core' +import { BaseCommand } from '../base-command.js' +import { assertStrictContainer, nowIso, requireManifest, resolveEngineFor, resolveEnvName } from '../cli/context.js' +import { buildEnvironment } from '../core/build-pipeline.js' +import { planEnvironment } from '../core/plan.js' +import { findFreePort, isContainerRunning, resolveDcwInvocation } from '../core/ssh/attach.js' +import { detectEditor, EDITOR_IDS, editorDisplayName, launchEditor, type EditorId } from '../core/ssh/editors.js' +import { ensureKeypair, knownHostsPath } from '../core/ssh/keys.js' +import { provisionContainerSsh, startSshDaemon } from '../core/ssh/provision.js' +import { hostAlias, writeSshConfig } from '../core/ssh/ssh-config.js' +import { containerName, planHasNetworkNone, upEnvironment } from '../core/up-pipeline.js' +import { DcwError, ValidationError } from '../errors.js' +import { enforceStrict, hardeningReport, translate, type HardeningReport } from '../hardening/translator.js' +import type { EnvManifest, SshState } from '../state/manifest.js' +import { saveManifest } from '../state/store.js' + +interface AttachJson { + name: string + engine: string + host: string + mode: 'exec' | 'port' + port?: number + user: string + folder: string + identityFile: string + /** Ready-to-run command to connect manually. */ + ssh: string + editor?: EditorId + launched: boolean + /** How the container is actually hardened — see `HardeningReport`. Reported whether + * this invocation started the container or reused a running one. */ + hardening: HardeningReport +} + +export default class Attach extends BaseCommand { + static description = + 'Attach an SSH-remote editor (Zed, VS Code, Cursor, Antigravity, …) to the environment container.' + static examples = [ + '<%= config.bin %> attach', + '<%= config.bin %> attach my-env --editor zed', + '<%= config.bin %> attach my-env --print', + '<%= config.bin %> attach my-env --port 2222', + ] + + static args = { + name: Args.string({ description: 'Environment name (defaults to the sole/.dcw environment).' }), + } + + static flags = { + editor: Flags.string({ + description: 'Editor to launch.', + options: [...EDITOR_IDS, 'none'], + }), + folder: Flags.string({ description: 'Remote folder to open.', default: '/workspace' }), + port: Flags.integer({ + description: 'Use a published TCP port instead of the default exec proxy (pass 0 to auto-allocate a free port).', + }), + print: Flags.boolean({ description: 'Print connection details only; do not launch an editor.', default: false }), + workspace: Flags.string({ description: 'Host directory to mount at /workspace if the container must be started.' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(Attach) + + // Pure flag validation first — it needs no environment. Zed's remote form is a + // URL (`ssh://`), so a relative folder silently yields a + // malformed target (`ssh://dcw-demowork`). Fail fast instead. + if (!flags.folder.startsWith('/')) { + throw new ValidationError( + `--folder must be an absolute path inside the container (got '${flags.folder}'). Try --folder /workspace.`, + ) + } + + const name = await resolveEnvName(args.name) + let manifest = await requireManifest(name) + + if (manifest.spec.ssh === false) { + throw new ValidationError( + `Environment '${name}' was created with --no-ssh, so its image has no SSH server. ` + + 'Recreate it without --no-ssh (or rebuild after enabling ssh) to use `dcw attach`.', + ) + } + + const usePort = flags.port !== undefined + const mode: SshState['mode'] = usePort ? 'port' : 'exec' + + // Reject an impossible port attach BEFORE touching the container. upEnvironment + // raises the same error, but startContainer force-removes the running container + // first — so `dcw attach --port` on an air-gapped env used to destroy a live + // container (losing an ephemeral tmpfs /workspace) and only then refuse. + if (usePort && planHasNetworkNone(planEnvironment(manifest.spec))) { + throw new ValidationError( + `Cannot publish an SSH port: '${name}' is hardened with network-none. ` + + 'Attach over the default (no-port) exec proxy instead — run `dcw attach` without --port.', + ) + } + + const { driver, engineName, capabilities } = await resolveEngineFor({ + requested: flags.engine, + manifestEngine: manifest.engine ?? manifest.spec.engine, + }) + + // Ensure a running container wired the way this mode needs it. + let port: number | undefined + const running = await isContainerRunning(driver, name) + + // `--strict` must fail closed on the reuse path too, not only when attach starts + // the container — otherwise `dcw attach --strict` hands an editor a container + // whose hardening the engine silently dropped. Evaluate it BEFORE provisioning + // keys, writing ~/.ssh/config or launching an editor, so a refusal leaves no + // trace. Judge a container we are REUSING by what was recorded when it actually + // started, not by re-running translate() against today's capability map: a + // container started before a capability-map change would otherwise be certified + // by rules it was never launched under. + let reusedHardening: HardeningReport | undefined + let startedHardening: HardeningReport | undefined + if (running && manifest.container) { + assertStrictContainer(manifest, flags.strict) + reusedHardening = { + appliedFlags: manifest.container.appliedFlags ?? [], + warnings: [], + dropped: manifest.container.droppedHardening ?? [], + unenforced: manifest.container.unenforcedHardening ?? [], + } + } + + const existingSsh = manifest.container?.ssh + const reusable = + usePort && running && existingSsh?.mode === 'port' && (flags.port === 0 || existingSsh.port === flags.port) + const willStart = usePort ? !reusable : !running + + // When this invocation will (re)start the container, the FRESH translation is + // what governs it — enforce --strict now, because startContainer force-removes + // the existing container before upEnvironment would reach the same verdict. + if (willStart && flags.strict) { + enforceStrict(translate(planEnvironment(manifest.spec).effects, capabilities, engineName)) + } + + if (usePort) { + const existing = existingSsh + if (reusable) { + port = existing!.port + } else { + port = flags.port && flags.port > 0 ? flags.port : await findFreePort() + const out = await this.startContainer({ manifest, driver, engineName, capabilities, flags, sshPublishPort: port }) + manifest = out.manifest + startedHardening = out.hardening + } + } else if (!running) { + const out = await this.startContainer({ manifest, driver, engineName, capabilities, flags }) + manifest = out.manifest + startedHardening = out.hardening + } + + const container = manifest.container?.id ?? manifest.container?.name ?? containerName(name) + const alias = hostAlias(name) + + // Keys: dcw's own pair, public half installed into the container's authorized_keys. + const keypair = await ensureKeypair() + await provisionContainerSsh({ driver, container, publicKey: keypair.publicKey, hostAlias: alias }) + + // Published-port mode needs a listening daemon; the exec proxy does not. + if (usePort) await startSshDaemon(driver, container) + + // Managed ~/.ssh/config block so every editor resolves `dcw-` identically. + const proxyCommand = usePort ? undefined : await resolveDcwInvocation(name) + await writeSshConfig({ + name, + mode, + identityFile: keypair.privateKeyPath, + knownHostsFile: knownHostsPath(), + proxyCommand, + port, + }) + + // Record how the env is attached. + if (manifest.container) { + manifest = { ...manifest, container: { ...manifest.container, ssh: { mode, port } }, updatedAt: nowIso() } + await saveManifest(manifest) + } + + // Pick + launch an editor unless suppressed. + const suppress = flags.print || this.jsonEnabled() || flags['no-input'] || flags.editor === 'none' + let editor: EditorId | undefined + let launched = false + if (!suppress) { + editor = (flags.editor as EditorId | undefined) ?? (await detectEditor()) + if (editor) { + launched = await launchEditor({ editor, alias, folder: flags.folder }) + if (!launched && flags.editor) { + throw new DcwError(`Editor '${editor}' is not installed (its CLI was not found on PATH).`) + } + } + } + + // Prefer the translation actually applied when we started the container; else + // the state recorded for the container we reused. + const hardening: HardeningReport = startedHardening ?? + reusedHardening ?? { + appliedFlags: manifest.container?.appliedFlags ?? [], + warnings: [], + dropped: manifest.container?.droppedHardening ?? [], + unenforced: manifest.container?.unenforcedHardening ?? [], + } + + const sshCmd = `ssh ${alias}` + if (!this.jsonEnabled()) { + this.log(`Ready: ${alias} (${mode === 'port' ? `localhost:${port}` : 'exec proxy'}) on ${driver.displayName}.`) + if (launched && editor) this.log(`Launched ${editorDisplayName(editor)} → ${flags.folder}.`) + else if (editor && flags.editor) this.log(`Could not launch ${editorDisplayName(editor)}.`) + this.log(`\nConnect manually: ${sshCmd}`) + this.log(`VS Code / Cursor: code --remote ssh-remote+${alias} ${flags.folder}`) + this.log(`Zed: zed ssh://${alias}${flags.folder}`) + } + + return { + name, + engine: engineName, + host: alias, + mode, + port, + user: 'vscode', + folder: flags.folder, + identityFile: keypair.privateKeyPath, + ssh: sshCmd, + editor, + launched, + hardening, + } + } + + /** Build (if needed) and start the container, optionally publishing an SSH port. */ + private async startContainer(opts: { + manifest: EnvManifest + driver: Awaited>['driver'] + engineName: Awaited>['engineName'] + capabilities: Awaited>['capabilities'] + flags: { strict?: boolean; workspace?: string } + sshPublishPort?: number + }): Promise<{ manifest: EnvManifest; hardening: HardeningReport }> { + const { driver, engineName, capabilities, flags } = opts + const name = opts.manifest.name + const plan = planEnvironment(opts.manifest.spec) + const now = nowIso() + + const built = await buildEnvironment({ manifest: opts.manifest, plan, driver, engineName, now }) + if (!this.jsonEnabled() && !built.skipped) this.log(`Built ${built.tag}.`) + await saveManifest(built.manifest) + + await driver.rm(containerName(name), { force: true }).catch(() => undefined) + const up = await upEnvironment({ + manifest: built.manifest, + plan, + driver, + capabilities, + engineName, + workspaceDir: flags.workspace ?? process.cwd(), + strict: flags.strict, + sshPublishPort: opts.sshPublishPort, + now, + }) + await saveManifest(up.manifest) + if (!this.jsonEnabled()) { + this.log(`Started ${containerName(name)} on ${driver.displayName}.`) + for (const w of up.translation.warnings) { + this.warn(`${w.level === 'dropped' ? 'dropped' : 'note'} [${w.effect}]: ${w.message}`) + } + } + return { manifest: up.manifest, hardening: hardeningReport(up.translation, up.runSpec.flags) } + } +} diff --git a/packages/core/src/commands/build.ts b/packages/core/src/commands/build.ts new file mode 100644 index 0000000..cd0f89a --- /dev/null +++ b/packages/core/src/commands/build.ts @@ -0,0 +1,78 @@ +import { Args, Flags } from '@oclif/core' +import { BaseCommand } from '../base-command.js' +import { nowIso, requireManifest, resolveEngineFor, resolveEnvName } from '../cli/context.js' +import { buildEnvironment } from '../core/build-pipeline.js' +import { planEnvironment } from '../core/plan.js' +import { saveManifest } from '../state/store.js' + +interface BuildJson { + name: string + engine: string + tag: string + imageId: string + skipped: boolean + failedTools: string[] + /** False when tool installs could not be verified (report unreadable). */ + toolsVerified: boolean +} + +export default class Build extends BaseCommand { + static description = 'Build the container image for an environment.' + static examples = ['<%= config.bin %> build', '<%= config.bin %> build my-env --force'] + + static args = { + name: Args.string({ description: 'Environment name (defaults to the sole/.dcw environment).' }), + } + + static flags = { + force: Flags.boolean({ description: 'Rebuild even if the image is up to date.', default: false }), + platform: Flags.string({ description: 'Target platform (e.g. linux/amd64).' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(Build) + const name = await resolveEnvName(args.name) + const manifest = await requireManifest(name) + const plan = planEnvironment(manifest.spec) + const { driver, engineName } = await resolveEngineFor({ + requested: flags.engine, + manifestEngine: manifest.spec.engine, + }) + + if (!this.jsonEnabled()) { + this.log(`Building '${name}' with ${driver.displayName}…`) + } + + const outcome = await buildEnvironment({ + manifest, + plan, + driver, + engineName, + force: flags.force, + platform: flags.platform, + now: nowIso(), + }) + await saveManifest(outcome.manifest) + + const failedTools = outcome.tools.filter((t) => !t.ok).map((t) => t.name) + if (!this.jsonEnabled()) { + this.log( + outcome.skipped + ? `Image ${outcome.tag} is up to date (skipped).` + : `Built ${outcome.tag} (${outcome.imageId.slice(0, 19)}).`, + ) + if (failedTools.length > 0) this.warn(`tools failed to install: ${failedTools.join(', ')}`) + else if (!outcome.toolsVerified) this.warn('could not read the in-image tool report; install status is unverified.') + } + + return { + name, + engine: engineName, + tag: outcome.tag, + imageId: outcome.imageId, + skipped: outcome.skipped, + failedTools, + toolsVerified: outcome.toolsVerified, + } + } +} diff --git a/packages/core/src/commands/create.ts b/packages/core/src/commands/create.ts new file mode 100644 index 0000000..231420b --- /dev/null +++ b/packages/core/src/commands/create.ts @@ -0,0 +1,188 @@ +import * as path from 'node:path' +import { Flags } from '@oclif/core' +import { BaseCommand } from '../base-command.js' +import { nowIso, resolveEngineFor } from '../cli/context.js' +import { buildEnvironment } from '../core/build-pipeline.js' +import { planEnvironment } from '../core/plan.js' +import { containerName, upEnvironment } from '../core/up-pipeline.js' +import { CancelledError, ValidationError } from '../errors.js' +import { enforceStrict, hardeningReport, translate, type HardeningReport } from '../hardening/translator.js' +import { flagsToSpec, type FlagInput } from '../spec/flags-to-spec.js' +import type { EnvSpec } from '../spec/env-spec.js' +import { SCHEMA_VERSION, type EnvManifest } from '../state/manifest.js' +import { loadManifest, saveManifest } from '../state/store.js' + +interface CreateJson { + name: string + spec: EnvSpec + built: boolean + started: boolean + container?: string + failedTools: string[] + toolsVerified: boolean + /** Present when the container was started (`--up`); mirrors `dcw up --json`. */ + hardening?: HardeningReport +} + +export default class Create extends BaseCommand { + static description = 'Create a container environment, interactively (wizard) or from flags.' + static examples = [ + '<%= config.bin %> create', + '<%= config.bin %> create --name audit --core-lang rust --framework foundry --sec slither --profile hardened', + '<%= config.bin %> create --no-input --framework foundry --build --json', + ] + + static flags = { + name: Flags.string({ description: 'Environment name (defaults to the current directory name).' }), + 'core-lang': Flags.string({ multiple: true, description: 'Core languages: rust, python, go, node.' }), + lang: Flags.string({ multiple: true, description: 'Smart-contract languages: solidity, vyper.' }), + framework: Flags.string({ multiple: true, description: 'Frameworks: foundry, hardhat, ape.' }), + fuzz: Flags.string({ multiple: true, description: 'Fuzzing/testing: echidna, medusa, halmos, ityfuzz, aderyn.' }), + sec: Flags.string({ multiple: true, description: 'Security tooling: slither, mythril, semgrep, heimdall, …' }), + 'ai-agent': Flags.string({ multiple: true, description: 'AI coding agents: claude, codex, opencode.' }), + profile: Flags.string({ + description: + "Security profile (development, hardened, airgapped, paranoid). Defaults to 'development' when neither --profile nor --harden is given; pass 'none' to opt out of hardening entirely.", + }), + harden: Flags.string({ multiple: true, description: 'Manual hardening keys (merged with --profile).' }), + 'git-url': Flags.string({ description: 'Clone this git repository into the image.' }), + 'git-branch': Flags.string({ description: 'Branch/tag to clone (requires --git-url).' }), + ssh: Flags.boolean({ + description: 'Bake an SSH server for editor attach (dcw attach). Use --no-ssh to omit.', + allowNo: true, + default: true, + }), + build: Flags.boolean({ description: 'Build the image after creating.', default: false }), + up: Flags.boolean({ description: 'Build and start the container after creating.', default: false }), + force: Flags.boolean({ description: 'Overwrite an existing environment with the same name.', default: false }), + } + + private flagInput(flags: Record): FlagInput { + return { + name: flags.name as string | undefined, + coreLanguages: flags['core-lang'] as string[] | undefined, + languages: flags.lang as string[] | undefined, + frameworks: flags.framework as string[] | undefined, + fuzzingAndTesting: flags.fuzz as string[] | undefined, + securityTooling: flags.sec as string[] | undefined, + aiAgents: flags['ai-agent'] as string[] | undefined, + profile: flags.profile as string | undefined, + hardening: flags.harden as string[] | undefined, + engine: flags.engine as string | undefined, + gitUrl: flags['git-url'] as string | undefined, + gitBranch: flags['git-branch'] as string | undefined, + ssh: flags.ssh as boolean | undefined, + fallbackName: path.basename(process.cwd()), + } + } + + async run(): Promise { + const { flags } = await this.parse(Create) + + const interactive = + Boolean(process.stdin.isTTY && process.stdout.isTTY) && + !this.jsonEnabled() && + !flags['no-input'] && + !flags.yes + + let spec: EnvSpec + if (interactive) { + // Lazily load the ink wizard so the JSON/non-TTY path never touches React. + const { mountWizard } = await import('../wizard/run.js') + const result = await mountWizard({ initial: this.flagInput(flags) }) + if (!result) throw new CancelledError('Environment creation cancelled.') + spec = result + } else { + spec = flagsToSpec(this.flagInput(flags)) + } + + if (!flags.force && (await loadManifest(spec.name))) { + throw new ValidationError(`Environment '${spec.name}' already exists. Pass --force to overwrite or choose a different --name.`) + } + + const now = nowIso() + const plan = planEnvironment(spec) + let manifest: EnvManifest = { + schemaVersion: SCHEMA_VERSION, + name: spec.name, + createdAt: now, + updatedAt: now, + spec, + resolved: { requiredTools: plan.tools.all, hardeningKeys: spec.hardening }, + engine: null, + image: null, + container: null, + } + await saveManifest(manifest) + if (!this.jsonEnabled()) this.log(`Created environment '${spec.name}'.`) + + let started = false + let containerId: string | undefined + let failedTools: string[] = [] + let toolsVerified = true + let hardening: HardeningReport | undefined + + if (flags.build || flags.up) { + const { driver, engineName, capabilities } = await resolveEngineFor({ + requested: flags.engine, + manifestEngine: spec.engine, + }) + // Same as `dcw up`: fail a doomed --strict run before paying for the build. + if (flags.up && flags.strict) enforceStrict(translate(plan.effects, capabilities, engineName)) + + const built = await buildEnvironment({ manifest, plan, driver, engineName, now }) + manifest = built.manifest + await saveManifest(manifest) + failedTools = built.tools.filter((t) => !t.ok).map((t) => t.name) + toolsVerified = built.toolsVerified + if (!this.jsonEnabled()) { + this.log(`Built ${built.tag}.`) + if (failedTools.length > 0) this.warn(`tools failed to install: ${failedTools.join(', ')}`) + else if (!toolsVerified) this.warn('could not read the in-image tool report; install status is unverified.') + } + + if (flags.up) { + await driver.rm(containerName(spec.name), { force: true }).catch(() => undefined) + const up = await upEnvironment({ + manifest, + plan, + driver, + capabilities, + engineName, + workspaceDir: process.cwd(), + strict: flags.strict, + now, + }) + manifest = up.manifest + await saveManifest(manifest) + started = true + containerId = up.containerId + // Surface dropped/unenforced hardening in JSON too: warnings below only + // reach humans, and a silently-dropped air-gap must not be invisible to + // the agents this flag exists for. + hardening = hardeningReport(up.translation, up.runSpec.flags) + if (!this.jsonEnabled()) { + this.log(`Started ${containerName(spec.name)}.`) + for (const w of up.translation.warnings) { + this.warn(`${w.level === 'dropped' ? 'dropped' : 'note'} [${w.effect}]: ${w.message}`) + } + } + } + } + + if (!this.jsonEnabled() && !flags.up) { + this.log(`Next: dcw up ${spec.name}`) + } + + return { + name: spec.name, + spec, + built: manifest.image !== null, + started, + container: containerId, + failedTools, + toolsVerified, + hardening, + } + } +} diff --git a/packages/core/src/commands/engines.ts b/packages/core/src/commands/engines.ts new file mode 100644 index 0000000..0b7c9ae --- /dev/null +++ b/packages/core/src/commands/engines.ts @@ -0,0 +1,76 @@ +import { BaseCommand } from '../base-command.js' +import { describeHost, detectHost } from '../engine/host.js' +import { surveyEngines, type EngineStatus } from '../engine/resolver.js' +import type { CapSupport } from '../engine/types.js' + +interface EngineReport { + name: string + displayName: string + supported: boolean + reason?: string + available?: boolean + version?: string + recommended: boolean + caveats: string[] + unsupported: string[] +} + +interface EnginesJson { + host: { os: string; arch: string; macosMajor?: number } + engines: EngineReport[] +} + +function collectCaps(status: EngineStatus): { caveats: string[]; unsupported: string[] } { + const caveats: string[] = [] + const unsupported: string[] = [] + for (const [key, cap] of Object.entries(status.capabilities)) { + const support = cap.support as CapSupport + if (support === 'caveated') caveats.push(`${key}: ${cap.note ?? 'caveat'}`) + else if (support === 'unsupported') unsupported.push(`${key}: ${cap.note ?? 'unsupported'}`) + } + return { caveats, unsupported } +} + +/** List container engines with platform support, availability, and hardening trade-offs. */ +export default class Engines extends BaseCommand { + static description = 'List container engines: platform support, availability, and hardening trade-offs.' + static examples = ['<%= config.bin %> engines', '<%= config.bin %> engines --json'] + + async run(): Promise { + const host = await detectHost() + const statuses = await surveyEngines({ host }) + + const engines: EngineReport[] = statuses.map((s) => { + const { caveats, unsupported } = collectCaps(s) + return { + name: s.name, + displayName: s.displayName, + supported: s.platform.supported, + reason: s.platform.reason, + available: s.detect?.available, + version: s.detect?.version, + recommended: s.recommended, + caveats, + unsupported, + } + }) + + if (!this.jsonEnabled()) { + this.log(`Host: ${describeHost(host)}\n`) + for (const e of engines) { + const mark = !e.supported ? ' ✗' : e.available ? (e.recommended ? '★ ' : '✓ ') : '· ' + const state = !e.supported + ? `unsupported (${e.reason ?? 'n/a'})` + : e.available + ? `available${e.version ? ` — ${e.version}` : ''}${e.recommended ? ' [recommended]' : ''}` + : 'not detected' + this.log(`${mark} ${e.displayName.padEnd(20)} ${state}`) + if (e.supported && e.unsupported.length > 0) { + this.log(` drops: ${e.unsupported.map((u) => u.split(':')[0]).join(', ')}`) + } + } + } + + return { host: { os: host.os, arch: host.arch, macosMajor: host.macosMajor }, engines } + } +} diff --git a/packages/core/src/commands/exec.ts b/packages/core/src/commands/exec.ts new file mode 100644 index 0000000..13e2c62 --- /dev/null +++ b/packages/core/src/commands/exec.ts @@ -0,0 +1,41 @@ +import { Args } from '@oclif/core' +import { BaseCommand, ignoredJsonFlag } from '../base-command.js' +import { execInto, resolveEnvName } from '../cli/context.js' +import { listManifests } from '../state/store.js' + +export default class Exec extends BaseCommand { + static description = 'Run a command inside an environment\'s running container.' + static examples = ['<%= config.bin %> exec my-env -- ls -la', '<%= config.bin %> exec -- forge --version'] + + // Streaming pass-through: the container's exit code is our exit code, so there + // is no JSON payload. `--json` would corrupt that code via oclif's ExitError. + static enableJsonFlag = false + + // Allow an arbitrary trailing command after the environment name. + static strict = false + + static args = { + name: Args.string({ description: 'Environment name (defaults to the sole/.dcw environment).' }), + } + + static flags = { + json: ignoredJsonFlag, + } + + async run(): Promise { + const { argv, flags } = await this.parse(Exec) + const tokens = argv as string[] + + // The first token is only the env name when it actually names an existing + // environment; otherwise it (and the rest) are the command to run, and the + // env is resolved with no positional (sole/.dcw). This keeps the documented + // `dcw exec -- forge --version` form working instead of mistaking 'forge' + // for an env name. + const first = tokens[0] + const namesEnv = first !== undefined && !first.startsWith('-') && (await listManifests()).some((m) => m.name === first) + const name = await resolveEnvName(namesEnv ? first : undefined) + const cmd = namesEnv ? tokens.slice(1) : tokens + const code = await execInto({ name, cmd, requested: flags.engine, strict: flags.strict }) + this.exit(code) + } +} diff --git a/packages/core/src/commands/logs.ts b/packages/core/src/commands/logs.ts new file mode 100644 index 0000000..e8b3b9a --- /dev/null +++ b/packages/core/src/commands/logs.ts @@ -0,0 +1,39 @@ +import { Args, Flags } from '@oclif/core' +import { BaseCommand, ignoredJsonFlag } from '../base-command.js' +import { requireManifest, resolveEngineFor, resolveEnvName } from '../cli/context.js' +import { containerName } from '../core/up-pipeline.js' +import { NotFoundError } from '../errors.js' + +export default class Logs extends BaseCommand { + static description = 'Show logs from an environment\'s container.' + static examples = ['<%= config.bin %> logs my-env', '<%= config.bin %> logs --follow'] + + // Streaming pass-through; the container's exit code is our exit code (no JSON). + static enableJsonFlag = false + + static args = { + name: Args.string({ description: 'Environment name (defaults to the sole/.dcw environment).' }), + } + + static flags = { + follow: Flags.boolean({ char: 'f', description: 'Follow log output.', default: false }), + tail: Flags.integer({ description: 'Number of lines to show from the end.', min: 0 }), + json: ignoredJsonFlag, + } + + async run(): Promise { + const { args, flags } = await this.parse(Logs) + const name = await resolveEnvName(args.name) + const manifest = await requireManifest(name) + if (!manifest.container?.name) { + throw new NotFoundError(`Environment '${name}' has no container.`) + } + const { driver } = await resolveEngineFor({ + requested: flags.engine, + manifestEngine: manifest.engine ?? manifest.spec.engine, + }) + const target = manifest.container.id ?? containerName(name) + const code = await driver.logs(target, { follow: flags.follow, tail: flags.tail }) + this.exit(code) + } +} diff --git a/packages/core/src/commands/ls.ts b/packages/core/src/commands/ls.ts new file mode 100644 index 0000000..a303a9c --- /dev/null +++ b/packages/core/src/commands/ls.ts @@ -0,0 +1,86 @@ +import { BaseCommand } from '../base-command.js' +import { resolveEngineFor } from '../cli/context.js' +import { containerName } from '../core/up-pipeline.js' +import type { ContainerInfo } from '../engine/types.js' +import { listManifests } from '../state/store.js' + +interface EnvRow { + name: string + engine: string | null + built: boolean + /** Live status reconciled against the engine: running | stopped | absent | unknown. */ + status: string + tools: number + hardening: number +} + +export default class Ls extends BaseCommand { + static description = 'List environments with their live container status.' + static examples = ['<%= config.bin %> ls', '<%= config.bin %> ls --json'] + + async run(): Promise<{ environments: EnvRow[] }> { + const { flags } = await this.parse(Ls) + const manifests = await listManifests() + + // Reconcile each environment against ITS OWN engine. Probing a single + // auto-detected engine reported every env created on a different one as + // 'absent' even while its container was running. + const byEngine = new Map() + const unreachable = new Set() + const targets = flags.engine + ? new Set([flags.engine]) + : new Set(manifests.map((m) => m.engine ?? m.spec.engine ?? null)) + + for (const engine of targets) { + try { + const { driver } = await resolveEngineFor({ + requested: flags.engine, + manifestEngine: engine ?? undefined, + }) + byEngine.set(engine, await driver.ps({ all: true })) + } catch { + // Engine missing or its daemon is down: we genuinely do not know whether + // those containers exist, so record it rather than asserting 'absent'. + unreachable.add(engine) + } + } + + const environments: EnvRow[] = manifests.map((m) => { + const key: string | null = flags.engine ?? m.engine ?? m.spec.engine ?? null + const live = (byEngine.get(key) ?? []).find((c) => c.name === containerName(m.name)) + const status = live + ? /up|running/i.test(live.status) + ? 'running' + : 'stopped' + : !m.container + ? 'never-started' + : unreachable.has(key) + ? // Distinguish "the engine could not tell us" from "it is gone". + 'unknown' + : 'absent' + return { + name: m.name, + engine: m.engine, + built: m.image !== null, + status, + tools: m.resolved.requiredTools.length, + hardening: m.resolved.hardeningKeys.length, + } + }) + + if (!this.jsonEnabled()) { + if (environments.length === 0) { + this.log('No environments yet. Create one with `dcw create`.') + } else { + this.log('NAME ENGINE BUILT STATUS') + for (const e of environments) { + this.log( + `${e.name.padEnd(20)} ${(e.engine ?? '-').padEnd(16)} ${(e.built ? 'yes' : 'no').padEnd(6)} ${e.status}`, + ) + } + } + } + + return { environments } + } +} diff --git a/packages/core/src/commands/rm.ts b/packages/core/src/commands/rm.ts new file mode 100644 index 0000000..a555778 --- /dev/null +++ b/packages/core/src/commands/rm.ts @@ -0,0 +1,97 @@ +import { Args, Flags } from '@oclif/core' +import { BaseCommand } from '../base-command.js' +import { nowIso, requireManifest, resolveEngineFor, resolveEnvName } from '../cli/context.js' +import { removeSshConfig } from '../core/ssh/ssh-config.js' +import { ENV_LABEL, containerName } from '../core/up-pipeline.js' +import { DcwError, ExitCode, ValidationError } from '../errors.js' +import { removeEnvironment, saveManifest } from '../state/store.js' +import type { EnvManifest } from '../state/manifest.js' + +interface RmJson { + name: string + removedContainer: boolean + purged: boolean +} + +export default class Rm extends BaseCommand { + static description = 'Remove an environment\'s container (and optionally the environment itself).' + static examples = ['<%= config.bin %> rm my-env', '<%= config.bin %> rm my-env --purge --yes'] + + static args = { + name: Args.string({ description: 'Environment name (defaults to the sole/.dcw environment).' }), + } + + static flags = { + purge: Flags.boolean({ description: 'Also delete the environment record, state, and generated Containerfile.', default: false }), + } + + async run(): Promise { + const { args, flags } = await this.parse(Rm) + const name = await resolveEnvName(args.name) + + if (flags.purge && !flags.yes) { + throw new DcwError(`Refusing to purge '${name}' without confirmation. Re-run with --yes.`, ExitCode.UsageError, 'E_CONFIRM') + } + + // A manifest that no longer validates (written by an older dcw, hand-edited, + // or rejected by a tightened schema) must still be purgeable — otherwise it is + // stuck: hidden from `ls` and unremovable. Under --purge we fall back to a + // best-effort container removal by label and then delete the record anyway. + let manifest: EnvManifest | null = null + try { + manifest = await requireManifest(name) + } catch (err) { + if (!(flags.purge && err instanceof ValidationError)) throw err + this.warn(`${err.message} Purging the invalid record anyway.`) + } + + // Purging is mostly local-state work, so it must not be held hostage by the + // engine: if Docker was uninstalled or its daemon is down, `dcw rm --purge` + // would otherwise fail with E_NO_ENGINE and leave the environment permanently + // undeletable. Resolve best-effort under --purge and skip container removal. + let driver: Awaited>['driver'] | undefined + try { + ;({ driver } = await resolveEngineFor({ + requested: flags.engine, + manifestEngine: manifest?.engine ?? manifest?.spec.engine, + })) + } catch (err) { + if (!flags.purge) throw err + this.warn( + `${err instanceof Error ? err.message : String(err)} Purging local state anyway; ` + + 'any leftover container must be removed with your engine directly.', + ) + } + + // Remove the container only when one is actually present. A missing/already- + // removed container is a benign no-op; a genuine engine failure surfaces as a + // typed DcwError rather than being swallowed. + let removedContainer = false + if (driver && (!manifest || manifest.container)) { + const target = manifest?.container?.id ?? containerName(name) + const present = await driver.ps({ label: `${ENV_LABEL}=${name}`, all: true }) + if (present.length > 0) { + await driver.rm(target, { force: true }) + removedContainer = true + } + } + + if (flags.purge) { + await removeEnvironment(name) + await removeSshConfig(name).catch(() => undefined) + if (!this.jsonEnabled()) this.log(`Purged environment '${name}'.`) + return { name, removedContainer, purged: true } + } + + // Non-purge path: manifest is guaranteed valid here (invalid ones threw above). + await saveManifest({ ...manifest!, container: null, updatedAt: nowIso() }) + if (!this.jsonEnabled()) { + this.log( + removedContainer + ? `Removed container for '${name}'. Run \`dcw up ${name}\` to restart.` + : `No container to remove for '${name}'.`, + ) + } + return { name, removedContainer, purged: false } + } +} diff --git a/packages/core/src/commands/schema.ts b/packages/core/src/commands/schema.ts new file mode 100644 index 0000000..dd21ddb --- /dev/null +++ b/packages/core/src/commands/schema.ts @@ -0,0 +1,16 @@ +import { BaseCommand } from '../base-command.js' +import { buildSchemaDoc, type SchemaDoc } from '../spec/schema-doc.js' + +export default class Schema extends BaseCommand { + static description = 'Print the machine-readable schema of all environment options (for agents/tooling).' + static examples = ['<%= config.bin %> schema', '<%= config.bin %> schema --json'] + + async run(): Promise { + const doc = buildSchemaDoc(this.config.version) + // Always emit JSON — this command is machine-facing. + if (!this.jsonEnabled()) { + this.log(JSON.stringify(doc, null, 2)) + } + return doc + } +} diff --git a/packages/core/src/commands/shell.ts b/packages/core/src/commands/shell.ts new file mode 100644 index 0000000..c741e68 --- /dev/null +++ b/packages/core/src/commands/shell.ts @@ -0,0 +1,26 @@ +import { Args } from '@oclif/core' +import { BaseCommand, ignoredJsonFlag } from '../base-command.js' +import { execInto, resolveEnvName } from '../cli/context.js' + +export default class Shell extends BaseCommand { + static description = 'Open an interactive zsh shell inside the environment\'s container.' + static examples = ['<%= config.bin %> shell', '<%= config.bin %> shell my-env'] + + // Streaming pass-through; the container's exit code is our exit code (no JSON). + static enableJsonFlag = false + + static args = { + name: Args.string({ description: 'Environment name (defaults to the sole/.dcw environment).' }), + } + + static flags = { + json: ignoredJsonFlag, + } + + async run(): Promise { + const { args, flags } = await this.parse(Shell) + const name = await resolveEnvName(args.name) + const code = await execInto({ name, cmd: ['zsh'], requested: flags.engine, strict: flags.strict }) + this.exit(code) + } +} diff --git a/packages/core/src/commands/skill.ts b/packages/core/src/commands/skill.ts new file mode 100644 index 0000000..e077caa --- /dev/null +++ b/packages/core/src/commands/skill.ts @@ -0,0 +1,28 @@ +import { BaseCommand, ignoredJsonFlag } from '../base-command.js' +import { readSkill } from '../skill.js' + +/** + * Print the packaged agent skill (SKILL.md) to stdout. + * + * Also reachable as the top-level `dcw --skill` flag (aliased in bin/), so agents + * and humans can install it with e.g. + * `dcw --skill > ~/.claude/skills/dcw/SKILL.md`. + */ +export default class Skill extends BaseCommand { + static description = 'Print the agent skill file (SKILL.md) teaching AI agents how to drive dcw, and exit.' + static examples = [ + '<%= config.bin %> --skill', + '<%= config.bin %> skill', + 'mkdir -p ~/.claude/skills/dcw && <%= config.bin %> --skill > ~/.claude/skills/dcw/SKILL.md', + ] + + // Raw markdown pass-through: no JSON envelope, but accept --json as a no-op. + static enableJsonFlag = false + // None of the global engine/prompt flags apply to a static print. + static baseFlags = {} as typeof BaseCommand.baseFlags + static flags = { json: ignoredJsonFlag } + + async run(): Promise { + process.stdout.write(readSkill()) + } +} diff --git a/packages/core/src/commands/ssh-proxy.ts b/packages/core/src/commands/ssh-proxy.ts new file mode 100644 index 0000000..48040e5 --- /dev/null +++ b/packages/core/src/commands/ssh-proxy.ts @@ -0,0 +1,43 @@ +import { Args } from '@oclif/core' +import { BaseCommand } from '../base-command.js' +import { requireManifest, resolveEngineFor, resolveEnvName } from '../cli/context.js' +import { SSHD_CONFIG_PATH } from '../containerfile/base.js' +import { NotFoundError } from '../errors.js' + +/** + * Hidden ProxyCommand target for `dcw attach` (default, no-port mode). SSH runs + * `dcw ssh-proxy ` and wires our stdin/stdout to the connection; we exec a + * one-shot `sshd -i` (inetd mode) as the vscode user inside the container, so the + * SSH session rides the engine's exec channel — no listening daemon, no open port, + * works under network-none. Writes nothing to stdout but the SSH protocol stream. + */ +export default class SshProxy extends BaseCommand { + static description = 'Internal: stdio SSH proxy into an environment container (used by `dcw attach`).' + static hidden = true + + static args = { + name: Args.string({ required: true, description: 'Environment name.' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(SshProxy) + const name = await resolveEnvName(args.name) + const manifest = await requireManifest(name) + if (!manifest.container?.name) { + throw new NotFoundError(`Environment '${name}' has no container. Start it with \`dcw up ${name}\`.`) + } + const { driver } = await resolveEngineFor({ + requested: flags.engine, + manifestEngine: manifest.engine ?? manifest.spec.engine, + }) + const target = manifest.container.id ?? manifest.container.name + const code = await driver.exec({ + container: target, + cmd: ['/usr/sbin/sshd', '-i', '-f', SSHD_CONFIG_PATH], + interactive: true, + tty: false, + user: 'vscode', + }) + this.exit(code) + } +} diff --git a/packages/core/src/commands/stop.ts b/packages/core/src/commands/stop.ts new file mode 100644 index 0000000..9d3a980 --- /dev/null +++ b/packages/core/src/commands/stop.ts @@ -0,0 +1,57 @@ +import { Args } from '@oclif/core' +import { BaseCommand } from '../base-command.js' +import { nowIso, requireManifest, resolveEngineFor, resolveEnvName } from '../cli/context.js' +import { ENV_LABEL, containerName } from '../core/up-pipeline.js' +import { saveManifest } from '../state/store.js' + +interface StopJson { + name: string + stopped: boolean +} + +export default class Stop extends BaseCommand { + static description = 'Stop an environment\'s running container.' + static examples = ['<%= config.bin %> stop', '<%= config.bin %> stop my-env'] + + static args = { + name: Args.string({ description: 'Environment name (defaults to the sole/.dcw environment).' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(Stop) + const name = await resolveEnvName(args.name) + const manifest = await requireManifest(name) + const { driver } = await resolveEngineFor({ + requested: flags.engine, + manifestEngine: manifest.engine ?? manifest.spec.engine, + }) + + // Nothing to stop if no container was ever recorded — benign and idempotent. + if (!manifest.container) { + if (!this.jsonEnabled()) this.log(`Nothing to stop for '${name}'.`) + return { name, stopped: false } + } + + // Reconcile against the engine: if the container is already gone, this is a + // benign no-op (don't pretend we stopped it). Only when the container is + // actually present do we issue the stop and let genuine engine failures + // surface as a typed DcwError. + const target = manifest.container.id ?? containerName(name) + const present = await driver.ps({ label: `${ENV_LABEL}=${name}`, all: true }) + if (present.length === 0) { + if (!this.jsonEnabled()) this.log(`Nothing to stop for '${name}' (no container).`) + return { name, stopped: false } + } + + await driver.stop(target) + + await saveManifest({ + ...manifest, + container: { ...manifest.container, status: 'stopped' }, + updatedAt: nowIso(), + }) + + if (!this.jsonEnabled()) this.log(`Stopped ${target}.`) + return { name, stopped: true } + } +} diff --git a/packages/core/src/commands/up.ts b/packages/core/src/commands/up.ts new file mode 100644 index 0000000..89d6083 --- /dev/null +++ b/packages/core/src/commands/up.ts @@ -0,0 +1,106 @@ +import { Args, Flags } from '@oclif/core' +import { BaseCommand } from '../base-command.js' +import { nowIso, requireManifest, resolveEngineFor, resolveEnvName } from '../cli/context.js' +import { buildEnvironment } from '../core/build-pipeline.js' +import { planEnvironment } from '../core/plan.js' +import { containerName, upEnvironment } from '../core/up-pipeline.js' +import { + enforceStrict, + hardeningReport, + translate, + type HardeningReport, + type HardeningWarning, +} from '../hardening/translator.js' +import { saveManifest } from '../state/store.js' + +interface UpJson { + name: string + engine: string + containerId: string + appliedFlags: string[] + warnings: HardeningWarning[] + dropped: string[] + failedTools: string[] + toolsVerified: boolean + /** Same data as the three fields above plus `unenforced`, in the shared shape + * used by `create --json` and `attach --json`. */ + hardening: HardeningReport +} + +export default class Up extends BaseCommand { + static description = 'Build (if needed) and start a hardened container, ready to shell into.' + static examples = ['<%= config.bin %> up', '<%= config.bin %> up my-env --strict', '<%= config.bin %> up --workspace .'] + + static args = { + name: Args.string({ description: 'Environment name (defaults to the sole/.dcw environment).' }), + } + + static flags = { + rebuild: Flags.boolean({ description: 'Force an image rebuild before starting.', default: false }), + workspace: Flags.string({ description: 'Host directory to mount at /workspace (default: cwd).' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(Up) + const name = await resolveEnvName(args.name) + const manifest = await requireManifest(name) + const plan = planEnvironment(manifest.spec) + const { driver, engineName, capabilities } = await resolveEngineFor({ + requested: flags.engine, + manifestEngine: manifest.spec.engine, + }) + + // Check --strict BEFORE building. upEnvironment enforces it too, but only after + // the image is built — so a strict run that cannot succeed would spend minutes + // building and then fail. The verdict depends only on plan + engine capabilities, + // both known now. + if (flags.strict) enforceStrict(translate(plan.effects, capabilities, engineName)) + + const now = nowIso() + const built = await buildEnvironment({ manifest, plan, driver, engineName, force: flags.rebuild, now }) + // Persist the image state immediately: if the run step below fails (--strict, + // port conflict, …) the next `dcw up` must not rebuild from scratch. + if (!built.skipped) await saveManifest(built.manifest) + const failedTools = built.tools.filter((t) => !t.ok).map((t) => t.name) + if (!this.jsonEnabled() && !built.skipped) { + this.log(`Built ${built.tag}.`) + if (failedTools.length > 0) this.warn(`tools failed to install: ${failedTools.join(', ')}`) + else if (!built.toolsVerified) this.warn('could not read the in-image tool report; install status is unverified.') + } + + // Fresh start: remove any prior container with the same name (best-effort). + await driver.rm(containerName(name), { force: true }).catch(() => undefined) + + const outcome = await upEnvironment({ + manifest: built.manifest, + plan, + driver, + capabilities, + engineName, + workspaceDir: flags.workspace ?? process.cwd(), + strict: flags.strict, + now, + }) + await saveManifest(outcome.manifest) + + if (!this.jsonEnabled()) { + this.log(`Started ${containerName(name)} on ${driver.displayName} (${outcome.containerId.slice(0, 12)}).`) + for (const w of outcome.translation.warnings) { + this.warn(`${w.level === 'dropped' ? 'dropped' : 'note'} [${w.effect}]: ${w.message}`) + } + this.log(`\nShell in with: dcw shell ${name}`) + } + + return { + name, + engine: engineName, + containerId: outcome.containerId, + appliedFlags: outcome.runSpec.flags, + warnings: outcome.translation.warnings, + dropped: outcome.translation.dropped.map((e) => e.kind), + failedTools, + toolsVerified: built.toolsVerified, + hardening: hardeningReport(outcome.translation, outcome.runSpec.flags), + } + } +} diff --git a/packages/core/src/containerfile/base.ts b/packages/core/src/containerfile/base.ts new file mode 100644 index 0000000..ce590db --- /dev/null +++ b/packages/core/src/containerfile/base.ts @@ -0,0 +1,132 @@ +/** + * Base-image fragments for a plain Debian image that reproduces the exact + * runtime contract the install snippets assume: a non-root `vscode` user with + * uid 1000, `$HOME=/home/vscode`, zsh as login + RUN shell, passwordless sudo, + * and a PATH pre-seeded with ~/.local/bin, ~/.cargo/bin, ~/.local/share/pnpm. + * + * Replaces the original devcontainer base image. `git` now comes from apt + * (it used to be a devcontainer feature); github-cli is intentionally dropped. + */ + +export const SYNTAX_DIRECTIVES = ['# syntax=docker/dockerfile:1.8', '# check=error=true'] + +export const ECHIDNA_STAGE = [ + '# Multi-stage build for Echidna', + 'FROM --platform=linux/amd64 ghcr.io/crytic/echidna/echidna:latest AS echidna', + '', +] + +/** FROM + base apt packages + non-root user + /workspace, all as root. */ +export const BASE_IMAGE = [ + '# Base image: Debian 13 (trixie) — current Debian stable', + 'FROM debian:trixie', + '', + '# Base packages (git replaces the old devcontainer git feature)', + 'RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \\', + ' bash-completion \\', + ' build-essential \\', + ' ca-certificates \\', + ' curl \\', + ' git \\', + ' gnupg \\', + ' jq \\', + ' locales \\', + ' pkg-config \\', + ' sudo \\', + ' unzip \\', + ' vim \\', + ' wget \\', + ' zsh \\', + ' && rm -rf /var/lib/apt/lists/*', + '', + "# Create the non-root 'vscode' user (uid 1000) with zsh + passwordless sudo", + 'RUN useradd --create-home --shell /usr/bin/zsh --uid 1000 vscode \\', + ' && usermod -aG sudo vscode \\', + " && echo 'vscode ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/vscode \\", + ' && chmod 0440 /etc/sudoers.d/vscode \\', + ' && mkdir -p /workspace && chown vscode:vscode /workspace', +] + +/** Python apt deps, installed as root before dropping privileges. */ +export const PYTHON_APT = [ + '# Install Python build dependencies', + 'RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \\', + ' python3-pip \\', + ' libpython3-dev \\', + ' python3-dev \\', + ' python3-venv \\', + ' && rm -rf /var/lib/apt/lists/*', +] + +/** Drop to the vscode user and set up HOME/PATH/shell; matches the snippet contract. */ +export const USER_ENV = [ + '# Switch to vscode (drop privileges)', + 'USER vscode', + 'WORKDIR /home/vscode', + 'ENV HOME=/home/vscode', + '# Update PATH', + 'ENV USR_LOCAL_BIN=/usr/local/bin', + 'ENV LOCAL_BIN=${HOME}/.local/bin', + 'ENV PNPM_HOME=${HOME}/.local/share/pnpm', + 'ENV PATH=${PATH}:${USR_LOCAL_BIN}:${LOCAL_BIN}:${PNPM_HOME}', + '# Ensure ~/.local/bin, ~/.zshrc, and the dcw install-report dir exist', + 'RUN mkdir -p ${HOME}/.local/bin ${HOME}/.dcw && touch ${HOME}/.zshrc', +] + +/** uv installer + Python 3.12, after the user switch (needs python apt deps). */ +export const UV_INSTALL = [ + '# Install uv', + 'RUN curl -LsSf https://astral.sh/uv/install.sh | sh', + 'ENV UV_LOCAL_BIN=$HOME/.cargo/bin', + 'ENV PATH=${PATH}:${USR_LOCAL_BIN}:${LOCAL_BIN}:${PNPM_HOME}:${UV_LOCAL_BIN}', + '# Install Python 3.12 with uv', + 'RUN uv python install 3.12', +] + +/** Set zsh as the RUN shell so subsequent snippets source ~/.zshrc as written. */ +export const SHELL_ZSH = [ + '# Use zsh for subsequent RUN commands', + 'ENV SHELL=/usr/bin/zsh', + 'SHELL ["/bin/zsh", "-ic"]', +] + +/** + * OpenSSH server for editor attach (Zed / VS Code / Cursor / Antigravity / any + * Remote-SSH editor). Designed to run **rootless**: `dcw attach` connects with + * `sshd -i` (inetd mode) exec'd as the vscode user, so there is no listening + * daemon and no open ports by default — it works even under `network-none` and + * `drop-caps=ALL`. + * + * The sshd config is baked at /etc/ssh/sshd_config.dcw (outside ~/.ssh, so a + * readonly-os tmpfs over ~/.ssh can't shadow it). Host key + authorized_keys live + * under ~/.ssh, which is writable in every mode: the real rootfs dir normally, a + * tmpfs under readonly-os. `dcw attach` generates the host key at runtime if the + * tmpfs starts empty and injects the public key — neither is required to be baked. + * `StrictModes no` keeps sshd from refusing the home dir under a tmpfs mount. + */ +export const SSHD_SETUP = [ + '# OpenSSH server for editor attach over SSH (rootless `sshd -i`).', + 'RUN sudo apt-get update \\', + ' && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \\', + ' openssh-server \\', + ' && sudo rm -rf /var/lib/apt/lists/* \\', + ' && mkdir -p ${HOME}/.ssh && chmod 700 ${HOME}/.ssh \\', + ' && ssh-keygen -q -t ed25519 -N "" -f ${HOME}/.ssh/ssh_host_ed25519_key \\', + ' && touch ${HOME}/.ssh/authorized_keys && chmod 600 ${HOME}/.ssh/authorized_keys \\', + ' && printf "%s\\n" \\', + ' "HostKey /home/vscode/.ssh/ssh_host_ed25519_key" \\', + ' "PidFile /home/vscode/.ssh/sshd.pid" \\', + ' "UsePAM no" \\', + ' "PasswordAuthentication no" \\', + ' "PubkeyAuthentication yes" \\', + ' "AuthorizedKeysFile /home/vscode/.ssh/authorized_keys" \\', + ' "Subsystem sftp internal-sftp" \\', + ' "AllowTcpForwarding yes" \\', + ' "PermitUserEnvironment no" \\', + ' "StrictModes no" \\', + ' "LogLevel QUIET" \\', + ' | sudo tee /etc/ssh/sshd_config.dcw > /dev/null', +] + +/** Absolute path to the sshd config baked by SSHD_SETUP (outside ~/.ssh). */ +export const SSHD_CONFIG_PATH = '/etc/ssh/sshd_config.dcw' diff --git a/packages/core/src/containerfile/generate.ts b/packages/core/src/containerfile/generate.ts new file mode 100644 index 0000000..47beb0c --- /dev/null +++ b/packages/core/src/containerfile/generate.ts @@ -0,0 +1,148 @@ +import { resolveTools, type Selections } from '../domain/dependency-resolver.js' +import { INSTALL_COMMANDS } from '../domain/install-commands.js' +import { + BASE_IMAGE, + ECHIDNA_STAGE, + PYTHON_APT, + SHELL_ZSH, + SSHD_SETUP, + SYNTAX_DIRECTIVES, + USER_ENV, + UV_INSTALL, +} from './base.js' +import { guardToolSnippet, REPORT_PATH } from './guard.js' +import { PRE_INSTALL_SHIMS } from './shims.js' + +export interface GitRepository { + url: string + branch?: string + enabled: boolean +} + +/** + * Reject a value that would break out of the unquoted `RUN git clone` line, or + * that `git` itself would parse as an option rather than an operand. + * + * A leading `-` turns the value into a flag: `git clone --upload-pack= ` + * executes `` at build time. The zod schema blocks this at the CLI boundary, + * but this guard is the last check before the value is spliced into the RUN line, + * so it must cover the case independently. + */ +function assertCloneSafe(label: string, value: string): void { + if (/[\s;`$(){}<>|&'"\\]/.test(value)) { + throw new Error(`Refusing to generate Containerfile: ${label} contains unsafe characters.`) + } + if (value.startsWith('-')) { + throw new Error(`Refusing to generate Containerfile: ${label} is unsafe (starts with '-', which git reads as an option).`) + } +} + +export interface ContainerfileInput { + selections: Selections + gitRepository?: GitRepository + /** Install an SSH server for editor attach (default true). */ + ssh?: boolean +} + +/** + * Generate a Containerfile (Dockerfile) for a plain-Debian, shell-first image. + * Assembles base fragments + per-tool snippets in the canonical install order: + * runtimes (rust, go, node) → python (inline uv) → remaining tools. + */ +export function generateContainerfile(input: ContainerfileInput): string { + const resolved = resolveTools(input.selections) + const lines: string[] = [] + + lines.push(...SYNTAX_DIRECTIVES, '') + + if (resolved.all.includes('echidna')) { + lines.push(...ECHIDNA_STAGE) + } + + lines.push(...BASE_IMAGE, '') + + if (resolved.needsPython) { + lines.push(...PYTHON_APT, '') + } + + lines.push(...USER_ENV, '') + + // SSH server for editor attach (rootless). Enabled unless explicitly disabled. + if (input.ssh !== false) { + lines.push(...SSHD_SETUP, '') + } + + if (resolved.needsPython) { + lines.push(...UV_INSTALL, '') + } + + lines.push(...SHELL_ZSH, '') + + // Core runtimes first (rust, go, node) — fail-fast, foundational. Python is inline above. + for (const runtime of resolved.runtimes) { + lines.push(snippet(runtime), '') + } + + // Leaf tools are best-effort: a failed install records `=fail` and the + // build continues (runtimes already succeeded). Optional compat shims first. + for (const tool of resolved.tools) { + lines.push(`# Install ${tool} (best-effort)`) + const shim = PRE_INSTALL_SHIMS[tool] + if (shim) lines.push(guardToolSnippet(tool, shim)) + lines.push(guardToolSnippet(tool, snippet(tool)), '') + } + + if (resolved.all.includes('echidna')) { + lines.push( + 'USER root', + '# Copy Echidna binary from the echidna stage', + 'COPY --from=echidna /usr/local/bin/echidna /usr/local/bin/echidna', + 'RUN chmod 755 /usr/local/bin/echidna', + 'USER vscode', + '', + ) + } + + if (input.gitRepository?.enabled && input.gitRepository.url) { + // Defense in depth: the schema already validates these, but never splice a + // value carrying a newline or shell metacharacter into the RUN line. + assertCloneSafe('git url', input.gitRepository.url) + if (input.gitRepository.branch) assertCloneSafe('git branch', input.gitRepository.branch) + const branch = input.gitRepository.branch ? `--branch ${input.gitRepository.branch} ` : '' + lines.push( + '# Clone git repository', + 'RUN mkdir -p /home/vscode/repos \\', + ` && git clone ${branch}${input.gitRepository.url} /home/vscode/repos/project \\`, + ' && sudo chown -R vscode:vscode /home/vscode/repos', + '', + ) + } + + const hasLeafTools = resolved.tools.length > 0 + lines.push( + '# Final setup', + "RUN echo 'Development environment ready!' && \\", + " echo 'Tools installed:' && \\", + ' ls -la $HOME/.local/bin/ || true', + '', + ) + if (hasLeafTools) { + lines.push( + '# Tool install report (best-effort installs)', + `RUN echo '--- dcw tool install report ---' && cat ${REPORT_PATH} 2>/dev/null || true`, + '', + ) + } + lines.push('WORKDIR /workspace', '') + + return lines.join('\n') +} + +function snippet(tool: string): string { + const value = INSTALL_COMMANDS[tool as keyof typeof INSTALL_COMMANDS] + if (value === undefined) { + throw new Error(`No install command for tool '${tool}'.`) + } + // Trim the leading/trailing blank lines that the template literals carry. + return value.replace(/^\n+/, '').replace(/\s+$/, '') +} diff --git a/packages/core/src/containerfile/guard.ts b/packages/core/src/containerfile/guard.ts new file mode 100644 index 0000000..983c8a2 --- /dev/null +++ b/packages/core/src/containerfile/guard.ts @@ -0,0 +1,67 @@ +/** + * Best-effort install wrapping. + * + * Leaf-tool install snippets are made non-fatal: each `RUN` is wrapped so a + * failure records `=fail` to the in-image report and continues the build, + * rather than aborting it. Non-RUN directives (ENV, WORKDIR, USER, COPY) are + * left untouched. Core runtimes are NOT wrapped — a failed runtime is a real + * problem and should fail fast. + */ + +export const REPORT_PATH = '/home/vscode/.dcw/report' + +interface Instruction { + isRun: boolean + lines: string[] +} + +const DIRECTIVES = ['RUN', 'ENV', 'WORKDIR', 'USER', 'COPY', 'ADD', 'ARG', 'LABEL', 'SHELL', 'ENTRYPOINT', 'CMD', 'FROM'] + +function startsInstruction(line: string): boolean { + const trimmed = line.trim() + if (trimmed === '' || trimmed.startsWith('#')) return false + const first = trimmed.split(/\s+/)[0] ?? '' + return DIRECTIVES.includes(first) +} + +/** Split a snippet into Dockerfile instructions, honoring `\` line continuations. */ +export function parseInstructions(text: string): Instruction[] { + const lines = text.split('\n') + const out: Instruction[] = [] + let current: Instruction | null = null + let continuing = false + + for (const line of lines) { + if (continuing && current) { + current.lines.push(line) + } else if (startsInstruction(line)) { + current = { isRun: line.trim().startsWith('RUN'), lines: [line] } + out.push(current) + } else { + // comment / blank / stray line → passthrough chunk + out.push({ isRun: false, lines: [line] }) + current = null + } + continuing = line.trimEnd().endsWith('\\') + } + return out +} + +/** Wrap every RUN in a tool's snippet so failures are recorded but non-fatal. */ +export function guardToolSnippet(tool: string, snippet: string): string { + const okEcho = `echo "${tool}=ok" >> ${REPORT_PATH}` + const failEcho = `{ echo "${tool}=fail" >> ${REPORT_PATH}; echo "dcw: ${tool} install failed (continuing)" >&2; }` + + return parseInstructions(snippet) + .map((inst) => { + if (!inst.isRun) return inst.lines.join('\n') + const lines = [...inst.lines] + // Open a subshell right after RUN on the first line. + lines[0] = lines[0]!.replace(/^(\s*)RUN\s+/, '$1RUN ( ') + // Close the subshell and append the success/failure guard on the last line. + const last = lines.length - 1 + lines[last] = `${lines[last]} ) && ${okEcho} || ${failEcho}` + return lines.join('\n') + }) + .join('\n') +} diff --git a/packages/core/src/containerfile/shims.ts b/packages/core/src/containerfile/shims.ts new file mode 100644 index 0000000..90cc5d3 --- /dev/null +++ b/packages/core/src/containerfile/shims.ts @@ -0,0 +1,38 @@ +import type { ToolKey } from '../domain/install-commands.js' + +/** + * Compatibility shims installed immediately before a tool whose upstream binary + * needs something modern Debian no longer ships. Each shim is a RUN-only snippet + * and is wrapped by the same best-effort guard as the tool it precedes. + */ + +// ityfuzz's prebuilt binary links against OpenSSL 1.1 (libssl.so.1.1), dropped +// in Debian 12+. Pull the last bullseye build from the Debian pool over HTTPS and +// verify it against the SHA-256 recorded in Debian's signed bullseye Packages +// index before handing it to dpkg (which runs maintainer scripts as root). +export const LIBSSL11_VERSION = '1.1.1w-0+deb11u1' +export const LIBSSL11_SHA256: Readonly> = { + amd64: 'aadf8b4b197335645b230c2839b4517aa444fd2e8f434e5438c48a18857988f7', + arm64: 'fe7a7d313c87e46e62e614a07137e4a476a79fc9e5aab7b23e8235211280fee3', +} + +const LIBSSL11_SHIM = ` +# Compatibility: install libssl1.1 (OpenSSL 1.1) for tools not yet built against OpenSSL 3. +# Sourced from the Debian bullseye (oldstable) main pool over HTTPS; the digest is pinned +# to the value published in Debian's signed bullseye Packages index. +RUN ARCH="$(dpkg --print-architecture)" && \ + case "$ARCH" in \ + amd64) SHA256="${LIBSSL11_SHA256.amd64}" ;; \ + arm64) SHA256="${LIBSSL11_SHA256.arm64}" ;; \ + *) echo "libssl1.1 shim: unsupported architecture $ARCH" >&2; exit 1 ;; \ + esac && \ + curl -fsSL --proto '=https' --tlsv1.2 -o /tmp/libssl1.1.deb \ + "https://deb.debian.org/debian/pool/main/o/openssl/libssl1.1_${LIBSSL11_VERSION}_\${ARCH}.deb" && \ + echo "$SHA256 /tmp/libssl1.1.deb" | sha256sum -c - && \ + sudo dpkg -i /tmp/libssl1.1.deb && \ + rm -f /tmp/libssl1.1.deb +` + +export const PRE_INSTALL_SHIMS: Partial> = { + ityfuzz: LIBSSL11_SHIM, +} diff --git a/packages/core/src/core/build-pipeline.ts b/packages/core/src/core/build-pipeline.ts new file mode 100644 index 0000000..b834b1f --- /dev/null +++ b/packages/core/src/core/build-pipeline.ts @@ -0,0 +1,124 @@ +import { REPORT_PATH } from '../containerfile/guard.js' +import type { EngineDriver, EngineName } from '../engine/types.js' +import type { EnvManifest, ToolStatus } from '../state/manifest.js' +import { envStateDir } from '../state/paths.js' +import { writeContainerfile } from '../state/store.js' +import type { ResolvedPlan } from './plan.js' + +/** Parse the in-image `=ok|fail` report; any `fail` line marks a tool failed. */ +export function parseToolReport(stdout: string): ToolStatus[] { + const status = new Map() + for (const line of stdout.split('\n')) { + const m = line.trim().match(/^(.+)=(ok|fail)$/) + if (!m) continue + const [, name, result] = m + const ok = result === 'ok' + // Any failure for a tool wins. + status.set(name!, (status.get(name!) ?? true) && ok) + } + return [...status.entries()].map(([name, ok]) => ({ name, ok })).sort((a, b) => a.name.localeCompare(b.name)) +} + +export interface BuildOptions { + manifest: EnvManifest + plan: ResolvedPlan + driver: EngineDriver + engineName: EngineName + force?: boolean + platform?: string + /** ISO timestamp (injected so the pipeline stays deterministic/testable). */ + now: string +} + +export interface BuildOutcome { + manifest: EnvManifest + imageId: string + tag: string + containerfilePath: string + /** True when an up-to-date image already existed and the build was skipped. */ + skipped: boolean + /** Per-tool best-effort install results (empty if there were no leaf tools). */ + tools: ToolStatus[] + /** + * False when leaf tools were expected but the in-image report could not be + * read — so an empty `tools` means "verification did not run", NOT "all clean". + */ + toolsVerified: boolean +} + +export function imageTag(name: string): string { + return `dcw/${name}:latest` +} + +/** + * Build the environment image. Writes the generated Containerfile, then skips + * the build when an up-to-date image already exists (same hash + same engine), + * unless `force` is set. Returns the manifest updated with image state. + */ +export async function buildEnvironment(opts: BuildOptions): Promise { + const { manifest, plan, driver, engineName, now } = opts + const tag = imageTag(manifest.name) + const cfPath = await writeContainerfile(manifest.name, plan.containerfile) + + const upToDate = + !opts.force && + manifest.image !== null && + manifest.image.containerfileHash === plan.containerfileHash && + manifest.image.imageId !== undefined && + manifest.engine === engineName + + if (upToDate && manifest.image) { + return { + manifest, + imageId: manifest.image.imageId!, + tag, + containerfilePath: cfPath, + skipped: true, + tools: manifest.image.tools ?? [], + toolsVerified: true, + } + } + + const { imageId } = await driver.build({ + containerfilePath: cfPath, + contextDir: envStateDir(manifest.name), + tag, + platform: opts.platform, + noCache: opts.force, + }) + + // Read back the best-effort install report from the freshly built image. The + // probe is a throwaway `cat` that needs neither network nor capabilities, so + // harden it where the engine supports it (a paranoid/airgapped env shouldn't + // spin up an unconstrained container just to read a file). + let tools: ToolStatus[] = [] + let toolsVerified = true + if (plan.tools.tools.length > 0) { + const probeFlags: string[] = [] + if (driver.capabilities.networkNone.support === 'supported') probeFlags.push('--network=none') + if (driver.capabilities.capDrop.support === 'supported') probeFlags.push('--cap-drop=ALL') + const report = await driver.runOnce(tag, ['cat', REPORT_PATH], probeFlags).catch(() => ({ stdout: '', code: 1 })) + // A non-zero probe means the report is unreadable — verification did not run. + toolsVerified = report.code === 0 + if (toolsVerified) tools = parseToolReport(report.stdout) + } + + const updated: EnvManifest = { + ...manifest, + engine: engineName, + image: { + tag, + imageId, + containerfileHash: plan.containerfileHash, + builtAt: now, + ...(tools.length > 0 ? { tools } : {}), + }, + resolved: { + requiredTools: plan.tools.all, + hardeningKeys: plan.spec.hardening, + }, + updatedAt: now, + } + + return { manifest: updated, imageId, tag, containerfilePath: cfPath, skipped: false, tools, toolsVerified } +} diff --git a/packages/core/src/core/devcontainer/devcontainerExec.ts b/packages/core/src/core/devcontainer/devcontainerExec.ts deleted file mode 100644 index a5d0ca3..0000000 --- a/packages/core/src/core/devcontainer/devcontainerExec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { spawn } from 'node:child_process' - -export async function devcontainerExec(containerId: string, openIn: string) { - if (openIn === 'shell') { - await new Promise((resolve, reject) => { - const exec = spawn( - 'npx', - [ - '@devcontainers/cli', - 'exec', - '--container-id', - containerId, - 'bash' - ], - { stdio: 'inherit' } - ); - - exec.on('error', (err) => { - reject(err); - }); - - exec.on('close', (code) => { - if (code !== 0) { - console.warn(`Shell session exited with code ${code}.`); - } - resolve(); - }); - }); - } else if (openIn === 'code') { - await new Promise((resolve, reject) => { - const exec = spawn( - 'code', - [ - '.', - ], - { stdio: 'inherit' } - ); - - exec.on('error', (err) => { - reject(err); - }); - - exec.on('close', (code) => { - if (code !== 0) { - console.warn(`VS Code session exited with code ${code}.`); - } - resolve(); - }); - }); - } else if (openIn === 'cursor') { - await new Promise((resolve, reject) => { - const exec = spawn( - 'cursor', - [ - '.', - ], - { stdio: 'inherit' } - ); - }); - } -} diff --git a/packages/core/src/core/devcontainer/devcontainerUp.ts b/packages/core/src/core/devcontainer/devcontainerUp.ts deleted file mode 100644 index 9b4e93c..0000000 --- a/packages/core/src/core/devcontainer/devcontainerUp.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { spawn } from 'node:child_process' -import { colorize } from '@/ui/styling/colors'; -import { symbols } from '@/ui/styling/symbols'; - -export async function devcontainerUp(devcontainerConfig: string) { - - const { containerId } = await new Promise<{ containerId: string; }>((resolve, reject) => { - const child = spawn( - 'npx', - ['@devcontainers/cli', 'up', '--config', devcontainerConfig, '--workspace-folder', '.'] - ); - - let output = ''; - let errorOutput = ''; - let logLines: string[] = []; - let frameIndex = 0; - let spinnerInterval: NodeJS.Timeout | null = null; - - // Function to update the 5-line display - const updateDisplay = () => { - // Clear previous lines and move cursor up - process.stdout.write('\x1B[5A\x1B[0J'); // Move up 5 lines and clear from cursor to end - - // Show spinner and building message - process.stdout.write(`\r${colorize.muted(symbols.spinner.frames[frameIndex])} Building devcontainer to finish setup...\n`); - - // Show last 4 log lines - for (let i = 0; i < 4; i++) { - const line = logLines[logLines.length - 4 + i] || ''; - const truncatedLine = line.length > 80 ? line.substring(0, 77) + '...' : line; - process.stdout.write(`\r${colorize.muted(' ' + truncatedLine)}\n`); - } - - frameIndex = (frameIndex + 1) % symbols.spinner.frames.length; - }; - - // Start the spinner - spinnerInterval = setInterval(updateDisplay, symbols.spinner.interval); - - child.stdout.on('data', (data) => { - const newOutput = data.toString(); - output += newOutput; - - // Split into lines and add to logLines - const lines = newOutput.split('\n').filter((line: string) => line.trim()); - logLines.push(...lines); - - // Keep only last 50 lines to avoid memory issues - if (logLines.length > 50) { - logLines = logLines.slice(-50); - } - }); - - child.stderr.on('data', (data) => { - const newErrorOutput = data.toString(); - errorOutput += newErrorOutput; - - // Also add stderr to log lines - const lines = newErrorOutput.split('\n').filter((line: string) => line.trim()); - logLines.push(...lines); - - // Keep only last 50 lines to avoid memory issues - if (logLines.length > 50) { - logLines = logLines.slice(-50); - } - }); - - child.on('error', (err) => { - if (spinnerInterval) { - clearInterval(spinnerInterval); - } - reject(err); - }); - - child.on('close', (code) => { - // Stop the spinner - if (spinnerInterval) { - clearInterval(spinnerInterval); - } - - // Clear the 5-line display - process.stdout.write('\x1B[5A\x1B[0J'); - - if (code !== 0) { - return reject(new Error(`'devcontainer up' exited with code ${code}:\n${errorOutput}`)); - } - - try { - const jsonMatch = output.match(/({[\s\S]*})/); - if (!jsonMatch) { - return reject(new Error('Could not find JSON output from devcontainer CLI.')); - } - - const result = JSON.parse(jsonMatch[1]); - - if (result.outcome !== 'success' || !result.containerId) { - return reject(new Error(`Dev container setup failed or did not return expected info. Output:\n${output}`)); - } - - resolve({ - containerId: result.containerId, - }); - - } catch (e) { - reject(new Error(`Failed to parse JSON from devcontainer CLI output: ${e}\nOutput was:\n${output}`)); - } - }); - }); - - return containerId; -} \ No newline at end of file diff --git a/packages/core/src/core/devcontainer/index.ts b/packages/core/src/core/devcontainer/index.ts deleted file mode 100644 index 3c1ec6c..0000000 --- a/packages/core/src/core/devcontainer/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export {devcontainerExec} from "./devcontainerExec" -export {devcontainerUp} from "./devcontainerUp" -export {prebuiltList} from "./prebuiltList" -export {copyPrebuiltContainer} from "./resolvePrebuiltPath" \ No newline at end of file diff --git a/packages/core/src/core/devcontainer/prebuiltList.ts b/packages/core/src/core/devcontainer/prebuiltList.ts deleted file mode 100644 index 2aa072a..0000000 --- a/packages/core/src/core/devcontainer/prebuiltList.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { Separator } from '@inquirer/core' -import { selectWithTopDescription } from '@/ui/components/selectWithTopDescription' -import { copyPrebuiltContainer } from '@/core/devcontainer/resolvePrebuiltPath' -import { colorize, symbols } from '@/ui/components' -import { shouldRun } from '@/utils/shouldRun' - -type PrebuiltChoice = { name: string; value: string; description: string; disabled: boolean, experimental?: boolean }; - -const PREBUILT_CHOICES: PrebuiltChoice[] = [ - { - name: 'Minimal 🧶', - value: 'minimal', - description: 'Use Hardhat and Foundry doing zero config.', - disabled: false - }, - { - name: 'Auditor 🔍', - value: 'auditor', - description: 'Audit smart contracts.', - disabled: false, - }, - { - name: 'Hardened 🛡️', - value: 'hardened', - description: 'Hardened environment with ephemeral workspace.', - disabled: false, - }, - { - name: 'Air-gapped ✈️', - value: 'airgapped', - description: 'Air-gapped environment.', - disabled: false, - }, - { - name: 'ETH Security Toolbox 📦️', - value: 'eth-security-toolbox', - description: 'Auditor environment with Trail of Bits selected tools', - disabled: false - }, - { - name: 'Legacy 🪷', - value: 'legacy', - description: 'The Red Guild\'s original devcontainer.', - disabled: false - }, - { - name: 'Paranoid 🔒', - value: 'paranoid', - description: 'Maximum security isolation, read-only OS and air-gapped environments.', - disabled: false, - experimental: true, - }, -]; - -export async function prebuiltList(options?: { listOnly?: boolean; selected?: string }): Promise { - const listOnly = options?.listOnly === true; - const selectedName = options?.selected; - - if (listOnly) { - const maxValueLength = Math.max(...PREBUILT_CHOICES.map(choice => choice.value.length)); - const paddingLength = maxValueLength + 2; - - for (const choice of PREBUILT_CHOICES) { - const paddedValue = choice.value.padEnd(paddingLength); - console.log(`${paddedValue}${choice.description}`); - } - return; - } - - const choices = []; - const regularChoices = PREBUILT_CHOICES.filter(choice => !choice.experimental); - const experimentalChoices = PREBUILT_CHOICES.filter(choice => choice.experimental); - - choices.push(...regularChoices); - if (experimentalChoices.length > 0) { - choices.push(new Separator("——— Experimental Profiles ———")); - choices.push(...experimentalChoices); - } - - let selected: any; - if (selectedName) { - const match = PREBUILT_CHOICES.find(c => c.value === selectedName); - if (!match) { - console.error(colorize.error(symbols.circle + ` Unknown prebuilt name: "${selectedName}"`)); - console.log(''); - return Symbol.for('back'); - } - selected = match.value; - } else { - selected = await selectWithTopDescription({ - message: 'Select a pre-built container to start:', - choices: choices, - footer: { back: true, exit: true }, - allowBack: true, - }); - if (selected === Symbol.for('back')) { - return Symbol.for('back'); - } - } - - console.log(colorize.brand(symbols.bullet + ' Copying selected devcontainer to current directory...')); - console.log(colorize.brand(symbols.check + ' Selected devcontainer copied successfully!')); - try { - const localConfigPath = await copyPrebuiltContainer(selected) - const runResult = await shouldRun(localConfigPath); - if (runResult === Symbol.for('back')) { - return Symbol.for('back'); - } - } catch (error) { - console.error(colorize.error(symbols.circle + ' Failed to copy devcontainer: ' + (error instanceof Error ? error.message : String(error)))); - console.log('') - } -} - -export function getPrebuiltChoices(): ReadonlyArray { - return PREBUILT_CHOICES; -} \ No newline at end of file diff --git a/packages/core/src/core/devcontainer/resolvePrebuiltPath.ts b/packages/core/src/core/devcontainer/resolvePrebuiltPath.ts deleted file mode 100644 index 26e7e89..0000000 --- a/packages/core/src/core/devcontainer/resolvePrebuiltPath.ts +++ /dev/null @@ -1,70 +0,0 @@ -import * as path from 'path' -import * as fs from 'fs/promises' -import { execSync } from 'child_process' - -const PREBUILT_REPO = 'https://github.com/theredguild/devcontainer.git' -const PREBUILT_CACHE_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.devcontainer-wizard', 'prebuilt-cache') - -// Ensure the cache directory exists -async function ensureCacheDir(): Promise { - await fs.mkdir(PREBUILT_CACHE_DIR, { recursive: true }) -} - -// Clone or update the prebuilt containers repository -async function ensurePrebuiltRepo(): Promise { - await ensureCacheDir() - - const repoPath = path.join(PREBUILT_CACHE_DIR, 'devcontainer') - - try { - // Check if directory exists and is a git repository - const stats = await fs.stat(repoPath) - if (stats.isDirectory()) { - // Try to update existing repository - try { - execSync('git pull', { cwd: repoPath, stdio: 'pipe' }) - } catch (error) { - // If update fails, remove and re-clone - await fs.rm(repoPath, { recursive: true, force: true }) - execSync(`git clone ${PREBUILT_REPO} "${repoPath}"`, { stdio: 'pipe' }) - } - } else { - // Directory exists but is not a directory, remove and clone - await fs.rm(repoPath, { recursive: true, force: true }) - execSync(`git clone ${PREBUILT_REPO} "${repoPath}"`, { stdio: 'pipe' }) - } - } catch (error) { - // Directory doesn't exist, clone it - execSync(`git clone ${PREBUILT_REPO} "${repoPath}"`, { stdio: 'pipe' }) - } - - return repoPath -} - -// Copy a prebuilt devcontainer folder to the current working directory -export async function copyPrebuiltContainer(containerName: string): Promise { - const repoPath = await ensurePrebuiltRepo() - const sourceFolder = path.join(repoPath, '.devcontainer', containerName) - const targetFolder = path.join(process.cwd(), '.devcontainer', containerName) - - // Create target directory - await fs.mkdir(targetFolder, { recursive: true }) - - // Copy all files from source to target - const entries = await fs.readdir(sourceFolder, { withFileTypes: true }) - - for (const entry of entries) { - const srcPath = path.join(sourceFolder, entry.name) - const destPath = path.join(targetFolder, entry.name) - - if (entry.isDirectory()) { - await fs.cp(srcPath, destPath, { recursive: true }) - } else { - await fs.copyFile(srcPath, destPath) - } - } - - return path.join(targetFolder, 'devcontainer.json') -} - - diff --git a/packages/core/src/core/plan.ts b/packages/core/src/core/plan.ts new file mode 100644 index 0000000..56dbcb6 --- /dev/null +++ b/packages/core/src/core/plan.ts @@ -0,0 +1,37 @@ +import { generateContainerfile } from '../containerfile/generate.js' +import { resolveTools, type ResolvedTools } from '../domain/dependency-resolver.js' +import type { HardeningKey } from '../domain/hardening.js' +import { hardeningToEffects, type HardeningEffect } from '../hardening/effects.js' +import type { EnvSpec } from '../spec/env-spec.js' +import { hashContainerfile } from '../state/store.js' + +export interface ResolvedPlan { + spec: EnvSpec + tools: ResolvedTools + effects: HardeningEffect[] + containerfile: string + containerfileHash: string +} + +/** Pure resolution: selections → tools, hardening → effects, → Containerfile. Engine-independent. */ +export function planEnvironment(spec: EnvSpec): ResolvedPlan { + const tools = resolveTools(spec.selections) + const effects = hardeningToEffects(spec.hardening as HardeningKey[]) + const containerfile = generateContainerfile({ + selections: spec.selections, + gitRepository: spec.gitRepository, + ssh: spec.ssh, + }) + return { + spec, + tools, + effects, + containerfile, + containerfileHash: hashContainerfile(containerfile), + } +} + +/** Whether an environment has an ephemeral (tmpfs) workspace rather than a bind mount. */ +export function isEphemeralWorkspace(plan: ResolvedPlan): boolean { + return plan.effects.some((e) => e.kind === 'ephemeral-workspace') +} diff --git a/packages/core/src/core/scripts/generate_dev_env.ts b/packages/core/src/core/scripts/generate_dev_env.ts deleted file mode 100644 index 861d1b6..0000000 --- a/packages/core/src/core/scripts/generate_dev_env.ts +++ /dev/null @@ -1,464 +0,0 @@ -import * as fs from 'fs/promises'; -import * as path from 'path'; -import { INSTALL_COMMANDS, ToolKey } from '@/core/scripts/install_commands'; -import { WizardState } from "@/types"; -import { colorize, symbols } from '@/ui/components'; -import { ui } from '@/ui/styling/ui'; -import { shouldRun } from '@/utils/shouldRun'; - -interface GenerationOptions { - configPath?: string; - config?: WizardState; -} - -export async function generateDevEnvironment(options: GenerationOptions = {}): Promise { - const { configPath = 'config.json', config: providedConfig } = options; - - let config: WizardState; - - if (providedConfig) { - config = providedConfig; - } else { - try { - const configFileContent = await fs.readFile(configPath, 'utf-8'); - config = JSON.parse(configFileContent) as WizardState; - } catch (error) { - throw new Error(`Failed to read configuration file: ${configPath}. ${error}`); - } - } - - ui.clearScreen() - console.log(colorize.brand(symbols.arrow + ' Starting development environment generation...')); - - const savePath = config.savePath || '.'; - const projectName = config.name || 'Web3 Dev Environment'; - const safeFolderName = projectName - .toLowerCase() - .replace(/[^a-z0-9._-]+/gi, '-') - .replace(/^-+|-+$/g, '') || 'default'; - - console.log(colorize.brand(symbols.bullet + ' Project: ' + projectName)); - console.log(colorize.brand(symbols.bullet + ' Save path: ' + savePath)); - console.log(colorize.brand(symbols.bullet + ' Folder name: ' + safeFolderName)); - - console.log(colorize.brand(symbols.diamond + ' Generating Dockerfile...')); - - const requiredDeps = new Set(); - - // Add core languages selected by user - config.coreLanguages?.forEach(lang => { - if (lang === 'rust') { - requiredDeps.add('rust'); - } else if (lang === 'python') { - requiredDeps.add('python'); - } else if (lang === 'go') { - requiredDeps.add('go'); - } else if (lang === 'node') { - requiredDeps.add('node'); - } - }); - - if (config.languages?.includes('solidity')) { - requiredDeps.add('python'); - requiredDeps.add('solc-select'); - } - if (config.languages?.includes('vyper')) { - requiredDeps.add('python'); - requiredDeps.add('vyper'); - } - - config.frameworks?.forEach(framework => { - if (framework === 'foundry') { - requiredDeps.add('rust'); - requiredDeps.add('foundry'); - } else if (framework === 'hardhat') { - requiredDeps.add('node'); - requiredDeps.add('hardhat'); - } else if (framework === 'ape') { - requiredDeps.add('python'); - requiredDeps.add('ape'); - } - }); - - config.fuzzingAndTesting?.forEach(tool => { - if (tool in INSTALL_COMMANDS) { - requiredDeps.add(tool as ToolKey); - if (['echidna', 'medusa'].includes(tool)) { - requiredDeps.add('go'); - } - if (tool === 'ityfuzz' || tool === 'aderyn') { - requiredDeps.add('rust'); - } - if (tool === 'halmos') { - requiredDeps.add('python'); - } - } - }); - - config.securityTooling?.forEach(tool => { - if (tool in INSTALL_COMMANDS) { - requiredDeps.add(tool as ToolKey); - if (['slither', 'mythril', 'crytic-compile', 'panoramix', 'slither-lsp', 'napalm-toolbox', 'semgrep', 'slitherin'].includes(tool)) { - requiredDeps.add('python'); - } - if (tool === 'heimdall') { - requiredDeps.add('rust'); - } - if (tool === 'panoramix') { - requiredDeps.add('tintinweb.vscode-decompiler'); - } - } - }); - - if (requiredDeps.size === 0) { - console.log(colorize.warning(symbols.triangle + ' No tools selected - creating minimal environment')); - } - - console.log(colorize.brand(symbols.diamond + ' Installing ' + requiredDeps.size + ' tools: ' + Array.from(requiredDeps).join(', '))); - - const dockerfileContent: string[] = []; - - dockerfileContent.push( - "# syntax=docker/dockerfile:1.8", - "# check=error=true" - ) - - - - if (requiredDeps.has('echidna')) { - dockerfileContent.push( - "# Multi-stage build for Echidna", - "# Echidna stage", - "FROM --platform=linux/amd64 ghcr.io/crytic/echidna/echidna:latest AS echidna", - "" - ); - } - - dockerfileContent.push( - "# Base image: Debian 12", - "FROM mcr.microsoft.com/vscode/devcontainers/base:bookworm", - "" - ); - - dockerfileContent.push( - "RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - bash-completion \ - build-essential \ - curl \ - git \ - jq \ - pkg-config \ - sudo \ - unzip \ - vim \ - wget \ - zsh \ - && rm -rf /var/lib/apt/lists/*" - ) - - if (requiredDeps.has('python')) { - dockerfileContent.push( - "# Install Python dependencies", - "RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - python3-pip \ - libpython3-dev \ - python3-dev \ - python3-venv \ - && rm -rf /var/lib/apt/lists/*" - ) - } - - dockerfileContent.push( - "# Switch to vscode (drop privs)", - "USER vscode", - "WORKDIR /home/vscode", - "ENV HOME=/home/vscode", - "# Update PATH", - "ENV USR_LOCAL_BIN=/usr/local/bin", - "ENV LOCAL_BIN=${HOME}/.local/bin", - "ENV PNPM_HOME=${HOME}/.local/share/pnpm", - "ENV PATH=${PATH}:${USR_LOCAL_BIN}:${LOCAL_BIN}:${PNPM_HOME}" - ) - - if (requiredDeps.has('python')) { - dockerfileContent.push( - `# Install uv - RUN curl -LsSf https://astral.sh/uv/install.sh | sh - # Update PATH environment - ENV UV_LOCAL_BIN=$HOME/.cargo/bin`, - "ENV PATH=${PATH}:${USR_LOCAL_BIN}:${LOCAL_BIN}:${PNPM_HOME}:${UV_LOCAL_BIN}", - `# Install Python 3.12 with uv - RUN uv python install 3.12` - ) - } - - dockerfileContent.push( - "# Set the default shell execution for subsequent RUN commands", - "ENV SHELL=/usr/bin/zsh", - `SHELL ["/bin/zsh", "-ic"]` - ) - - // Install core runtime dependencies first - const coreRuntimeOrder: ToolKey[] = ['rust', 'go', 'node']; - - for (const dep of coreRuntimeOrder) { - if (requiredDeps.has(dep)) { - dockerfileContent.push(INSTALL_COMMANDS[dep]); - dockerfileContent.push(""); - } - } - const alreadyInstalledTools = [...coreRuntimeOrder, 'python']; - - for (const tool of Array.from(requiredDeps)) { - if (!alreadyInstalledTools.includes(tool)) { - dockerfileContent.push(`# Install ${tool}`); - dockerfileContent.push(INSTALL_COMMANDS[tool]); - dockerfileContent.push(""); - } - } - - if (requiredDeps.has('echidna')) { - dockerfileContent.push( - "USER root", - "# Copy Echidna binary from echidna stage", - "COPY --from=echidna /usr/local/bin/echidna /usr/local/bin/echidna", - "RUN chmod 755 /usr/local/bin/echidna", - "USER vscode" - ); - } - - dockerfileContent.push( - "# Final setup", - - "RUN echo 'Development environment ready!' && \\", - "echo 'Tools installed:' && \\", - " ls -la $HOME/.local/bin/ || true", - "", - "WORKDIR /workspace" - ); - - // Load selectedHardening - const selectedHardening = new Set(config.systemHardening || []); - - // Git clone - if (config.gitRepository?.enabled && config.gitRepository.url) { - - const branchFlag = config.gitRepository.branch ? `--branch ${config.gitRepository.branch} ` : ''; - - dockerfileContent.push( - `# Clone git repo - RUN mkdir -p /home/vscode/repos \ - && git clone ${config.gitRepository.url} /home/vscode/repos/project \ - && chown -R vscode:vscode /home/vscode/repos` - ); - } - - // Create output directory - const devcontainerDir = path.join(savePath, '.devcontainer', safeFolderName); - try { - await fs.mkdir(devcontainerDir, { recursive: true }); - } catch (error) { - throw new Error(`Failed to create directory ${devcontainerDir}: ${error}`); - } - - // Write Dockerfile - const dockerfilePath = path.join(devcontainerDir, 'Dockerfile'); - try { - await fs.writeFile(dockerfilePath, dockerfileContent.join('\n')); - console.log(colorize.success(symbols.check + ' Dockerfile generated at: ' + dockerfilePath)); - } catch (error) { - throw new Error(`Failed to write Dockerfile: ${error}`); - } - - // --- Generate devcontainer.json --- - ui.clearScreen() - console.log(colorize.brand(symbols.bullet + ' Generating devcontainer.json...')); - - const devcontainerConfig: any = { - name: projectName, - build: { - dockerfile: "Dockerfile", - }, - remoteUser: "vscode", - - // Features for enhanced functionality - features: { - "ghcr.io/devcontainers/features/git:1": {}, - "ghcr.io/devcontainers/features/github-cli:1": {} - }, - - // Container environment - containerEnv: { - "DEVCONTAINER_ID_LABEL": `${safeFolderName}-web3-devcontainer` - }, - - // VS Code customizations - customizations: { - vscode: { - extensions: config.vscodeExtensions || [], - settings: { - "terminal.integrated.defaultProfile.linux": "zsh", - "terminal.integrated.profiles.linux": { - "zsh": { - "path": "/bin/zsh" - } - } - } - } - }, - - // Lifecycle commands - initializeCommand: `echo 'Initializing ${projectName} dev container...'`, - postStartCommand: "echo '🚀 Dev container is ready for Web3 development!'", - - // Workspace configuration - workspaceFolder: "/workspace" - }; - - // Add Tintin's Decompiler to VS Code extensions if it's required but not already in the config - if (requiredDeps.has('tintinweb.vscode-decompiler') && !config.vscodeExtensions?.includes('tintinweb.vscode-decompiler')) { - devcontainerConfig.customizations.vscode.extensions.push('tintinweb.vscode-decompiler'); - } - - // Workspace mounting strategy - - - if (selectedHardening.has('ephemeral-workspace')) { - devcontainerConfig.workspaceMount = "type=tmpfs,destination=/workspace,tmpfs-mode=1777"; - console.log(colorize.brand(symbols.diamond + ' Applied ephemeral workspace (tmpfs mount)')); - } else { - devcontainerConfig.workspaceMount = "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached"; - } - - if (config.gitRepository?.enabled && config.gitRepository.url) { - devcontainerConfig.postCreateCommand = 'mkdir -p /workspace/repo && cp -r /home/vscode/repos/project/. /workspace/repo' - } - - - // Docker run arguments for hardening - const runArgs: string[] = []; - - // Capabilities - if (selectedHardening.has('drop-caps')) { - runArgs.push('--cap-drop=ALL'); - } else if (selectedHardening.has('no-raw-packets')) { - runArgs.push('--cap-drop=NET_RAW'); - } - - // Read-only - - if (selectedHardening.has('readonly-os')) { - runArgs.push( - "--read-only", - // --- Writable, EXECUTABLE Mounts for VS Code Server --- - "--tmpfs", "/home/vscode/.vscode-server:rw,exec,nosuid,size=2g,uid=1000,gid=1000", - "--tmpfs", "/home/vscode/.vscode-server-insiders:rw,exec,nosuid,size=1g,uid=1000,gid=1000", - // --- Writable, NON-EXECUTABLE Mounts for Caches, Configs, and Logs --- - "--tmpfs", "/home/vscode/.cache:rw,noexec,nosuid,size=1g,uid=1000,gid=1000", - "--tmpfs", "/home/vscode/.config:rw,noexec,nosuid,size=512m,uid=1000,gid=1000", - "--tmpfs", "/home/vscode/.local:rw,noexec,nosuid,size=1g,uid=1000,gid=1000", - "--tmpfs", "/home/vscode/.gnupg:rw,noexec,nosuid,size=64m,uid=1000,gid=1000", - "--tmpfs", "/tmp:rw,noexec,nosuid,size=1g", - "--tmpfs", "/var/tmp:rw,noexec,nosuid,size=1g", - "--tmpfs", "/var/log:rw,noexec,nosuid,size=256m", - "--tmpfs", "/run:rw,noexec,nosuid,size=256m", - "--tmpfs", "/home/vscode/.devcontainer:rw,noexec,nosuid,size=128m,uid=1000,gid=1000" - ) - } - - - // Security options - if (selectedHardening.has('no-new-privs')) { - runArgs.push('--security-opt', 'no-new-privileges:true'); - } - if (selectedHardening.has('apparmor')) { - runArgs.push('--security-opt', 'apparmor=docker-default'); - } - - // Networking - if (selectedHardening.has('network-none')) { - runArgs.push('--network=none'); - } else if (selectedHardening.has('disable-ipv6')) { - runArgs.push('--sysctl', 'net.ipv6.conf.all.disable_ipv6=1', '--sysctl', 'net.ipv6.conf.default.disable_ipv6=1'); - } - if (selectedHardening.has('secure-dns')) { - runArgs.push('--dns=1.1.1.1', '--dns=1.0.0.1'); - } - - // Temporary directories - if (selectedHardening.has('secure-tmp')) { - runArgs.push('--tmpfs', '/tmp:rw,noexec,nosuid,size=512m', '--tmpfs', '/var/tmp:rw,noexec,nosuid,size=512m'); - } - - // Resource limits - if (selectedHardening.has('resource-limits')) { - runArgs.push('--memory=512m', '--cpus=2'); - } else if (selectedHardening.has('resource-limits-light')) { - runArgs.push('--memory=512m', '--cpus=2'); - } else if (selectedHardening.has('resource-limits-standard')) { - runArgs.push('--memory=2g', '--cpus=4'); - } else if (selectedHardening.has('resource-limits-heavy')) { - runArgs.push('--memory=4g', '--cpus=8'); - } - - if (runArgs.length > 0) { - devcontainerConfig.runArgs = runArgs; - } - - // VS Code security settings - if (selectedHardening.has('vscode-security')) { - devcontainerConfig.customizations.vscode.settings = { - ...devcontainerConfig.customizations.vscode.settings, - 'task.autoDetect': 'off', - 'task.allowAutomaticTasks': 'off', - 'security.workspace.trust.enabled': false, - 'telemetry.telemetryLevel': 'off', - }; - } - - // Write devcontainer.json - const devcontainerPath = path.join(devcontainerDir, 'devcontainer.json'); - try { - await fs.writeFile( - devcontainerPath, - JSON.stringify(devcontainerConfig, null, 2) - ); - console.log(colorize.success(symbols.check + ' devcontainer.json generated at: ' + devcontainerPath)); - } catch (error) { - throw new Error(`Failed to write devcontainer.json: ${error}`); - } - - // Generate summary - ui.clearScreen() - console.log('\n' + colorize.brand(symbols.diamond + ' Generation Summary:')); - console.log(' Project: ' + projectName); - console.log(' Location: ' + devcontainerDir); - console.log(' Core Languages: ' + (config.coreLanguages?.join(', ') || 'None')); - console.log(' Languages: ' + (config.languages?.join(', ') || 'None')); - console.log(' Frameworks: ' + (config.frameworks?.join(', ') || 'None')); - console.log(' Security Tools: ' + (config.securityTooling?.join(', ') || 'None')); - console.log(' Testing Tools: ' + (config.fuzzingAndTesting?.join(', ') || 'None')); - console.log(' VS Code Extensions: ' + (config.vscodeExtensions?.length || 0) + ' extensions'); - if (config.systemHardening?.length) { - console.log(' Security Hardening: ' + config.systemHardening.join(', ')); - } - if (config.gitRepository?.enabled && config.gitRepository.url) { - console.log(' Git Repository: ' + config.gitRepository.url + (config.gitRepository.branch ? ' (' + config.gitRepository.branch + ')' : '')); - } - - - await shouldRun(devcontainerPath); -} - -// Legacy function for backwards compatibility -export async function generateFiles(configPath: string = 'config.json'): Promise { - return generateDevEnvironment({ configPath }); -} - -// Auto-run script if called directly -if (require.main === module) { - generateFiles().catch(error => { - console.error(colorize.error(symbols.circle + ' Generation failed: ' + error.message)); - process.exit(1); - }); -} \ No newline at end of file diff --git a/packages/core/src/core/ssh/attach.ts b/packages/core/src/core/ssh/attach.ts new file mode 100644 index 0000000..f2a16e5 --- /dev/null +++ b/packages/core/src/core/ssh/attach.ts @@ -0,0 +1,49 @@ +import { createServer } from 'node:net' +import type { EngineDriver } from '../../engine/types.js' +import { isOnPath } from '../../util/which.js' +import { ENV_LABEL } from '../up-pipeline.js' + +/** Whether the env's container is currently running on the given engine. */ +export async function isContainerRunning(driver: EngineDriver, envName: string): Promise { + const found = await driver.ps({ label: `${ENV_LABEL}=${envName}`, all: true }) + return found.some((c) => /\bup\b|running/i.test(c.status)) +} + +/** Ask the OS for a free TCP port (bind :0, read it back, release). */ +export function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = createServer() + srv.on('error', reject) + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address() + if (addr && typeof addr === 'object') { + const port = addr.port + srv.close(() => resolve(port)) + } else { + srv.close(() => reject(new Error('Could not determine a free port.'))) + } + }) + }) +} + +/** + * The command string SSH should run as ProxyCommand to reach `dcw ssh-proxy`. + * Prefers `dcw` on PATH (stable across upgrades); otherwise falls back to the + * absolute ` ` of the currently running process so it also + * works for local/dev installs that aren't on PATH. + */ +export async function resolveDcwInvocation(name: string): Promise { + if (await isOnPath('dcw')) { + return `dcw ssh-proxy ${name}` + } + const entry = process.argv[1] + // ssh runs ProxyCommand via `/bin/sh -c`, so a node/entry path containing + // spaces (e.g. /Users/First Last/…) would word-split — quote both components. + if (entry) return `${shQuote(process.execPath)} ${shQuote(entry)} ssh-proxy ${name}` + return `dcw ssh-proxy ${name}` +} + +/** POSIX single-quote a string so it survives `/bin/sh -c` unsplit. */ +function shQuote(s: string): string { + return `'${s.replace(/'/g, "'\\''")}'` +} diff --git a/packages/core/src/core/ssh/editors.ts b/packages/core/src/core/ssh/editors.ts new file mode 100644 index 0000000..a7b961c --- /dev/null +++ b/packages/core/src/core/ssh/editors.ts @@ -0,0 +1,87 @@ +import { spawn } from 'node:child_process' +import { isOnPath } from '../../util/which.js' + +export type EditorId = 'zed' | 'vscode' | 'cursor' | 'antigravity' + +interface EditorDef { + id: EditorId + displayName: string + /** Candidate CLI binaries, in priority order. */ + bins: string[] + /** Build the argv that opens the remote `folder` on host `alias`. */ + args: (alias: string, folder: string) => string[] +} + +/** + * Editor launchers. Every entry resolves the container through the `~/.ssh/config` + * host alias dcw writes, so the same SSH plumbing works for all of them. VS Code, + * Cursor and Antigravity share the `--remote ssh-remote+` form (Antigravity + * is a VS Code fork); Zed takes an `ssh://` URL. + */ +const EDITORS: Record = { + zed: { + id: 'zed', + displayName: 'Zed', + bins: ['zed'], + args: (alias, folder) => [`ssh://${alias}${folder}`], + }, + vscode: { + id: 'vscode', + displayName: 'VS Code', + bins: ['code'], + args: (alias, folder) => ['--remote', `ssh-remote+${alias}`, folder], + }, + cursor: { + id: 'cursor', + displayName: 'Cursor', + bins: ['cursor'], + args: (alias, folder) => ['--remote', `ssh-remote+${alias}`, folder], + }, + antigravity: { + id: 'antigravity', + displayName: 'Antigravity', + bins: ['antigravity'], + args: (alias, folder) => ['--remote', `ssh-remote+${alias}`, folder], + }, +} + +export const EDITOR_IDS = Object.keys(EDITORS) as EditorId[] + +async function resolveBin(bins: string[]): Promise { + for (const bin of bins) { + if (await isOnPath(bin)) return bin + } + return undefined +} + +/** First installed editor among the preferred order, or undefined if none found. */ +export async function detectEditor(): Promise { + for (const id of EDITOR_IDS) { + if (await resolveBin(EDITORS[id].bins)) return id + } + return undefined +} + +export function editorDisplayName(id: EditorId): string { + return EDITORS[id].displayName +} + +/** + * Launch `editor` against the SSH host `alias`, opening `folder`. Spawns detached + * so dcw can return immediately. Resolves false if no binary was found. + */ +export async function launchEditor(opts: { + editor: EditorId + alias: string + folder: string +}): Promise { + const def = EDITORS[opts.editor] + const bin = await resolveBin(def.bins) + if (!bin) return false + const child = spawn(bin, def.args(opts.alias, opts.folder), { + detached: true, + stdio: 'ignore', + }) + child.unref() + return true +} diff --git a/packages/core/src/core/ssh/keys.ts b/packages/core/src/core/ssh/keys.ts new file mode 100644 index 0000000..6f10c43 --- /dev/null +++ b/packages/core/src/core/ssh/keys.ts @@ -0,0 +1,72 @@ +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import { DcwError } from '../../errors.js' +import { capture } from '../../engine/exec.js' +import { sshDir } from '../../state/paths.js' + +export interface DcwKeypair { + /** Absolute path to the private key (the editor's IdentityFile). */ + privateKeyPath: string + /** Absolute path to the public key. */ + publicKeyPath: string + /** Public key contents (single line, no trailing newline). */ + publicKey: string +} + +/** Absolute path to the file pinning container host keys for attach. */ +export function knownHostsPath(): string { + return path.join(sshDir(), 'known_hosts') +} + +/** + * Absolute path to the persisted container host private key for `alias`. Stored + * host-side so the container's SSH identity survives recreates and a tmpfs-backed + * ~/.ssh — keeping the known_hosts pin durable instead of a fresh TOFU each time. + */ +export function hostKeyPath(alias: string): string { + return path.join(sshDir(), 'hostkeys', alias) +} + +/** + * Ensure dcw's own ed25519 keypair exists under ~/.config/dcw/ssh and return it. + * dcw never touches the user's personal keys — it manages a dedicated pair used + * only for container attach. Generated once with `ssh-keygen`; idempotent. + */ +export async function ensureKeypair(): Promise { + const dir = sshDir() + await fs.mkdir(dir, { recursive: true, mode: 0o700 }) + const privateKeyPath = path.join(dir, 'id_ed25519') + const publicKeyPath = `${privateKeyPath}.pub` + + if (!(await exists(publicKeyPath)) || !(await exists(privateKeyPath))) { + const res = await capture('ssh-keygen', [ + '-q', + '-t', + 'ed25519', + '-N', + '', + '-C', + 'dcw-attach', + '-f', + privateKeyPath, + ]) + if (res.spawnError) { + throw new DcwError('ssh-keygen not found on PATH; install OpenSSH to use `dcw attach`.') + } + if (res.code !== 0) { + throw new DcwError(`Failed to generate dcw SSH key: ${res.stderr.trim() || `exit ${res.code}`}.`) + } + } + + const publicKey = (await fs.readFile(publicKeyPath, 'utf8')).trim() + return { privateKeyPath, publicKeyPath, publicKey } +} + +async function exists(p: string): Promise { + try { + await fs.access(p) + return true + } catch { + return false + } +} diff --git a/packages/core/src/core/ssh/provision.ts b/packages/core/src/core/ssh/provision.ts new file mode 100644 index 0000000..48aaeec --- /dev/null +++ b/packages/core/src/core/ssh/provision.ts @@ -0,0 +1,149 @@ +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import { DcwError } from '../../errors.js' +import type { EngineDriver } from '../../engine/types.js' +import { SSHD_CONFIG_PATH } from '../../containerfile/base.js' +import { SSH_CONTAINER_PORT } from '../up-pipeline.js' +import { hostKeyPath, knownHostsPath } from './keys.js' + +const CONTAINER_USER = 'vscode' +const HOST_KEY = '/home/vscode/.ssh/ssh_host_ed25519_key' +const HOST_KEY_PUB = `${HOST_KEY}.pub` + +/** + * Provision a running container for editor attach: install dcw's public key into + * the vscode user's authorized_keys (idempotent) and pin the container's host key + * under the given alias in dcw's known_hosts. The key travels via stdin so it is + * never interpolated into a shell command. + */ +export async function provisionContainerSsh(opts: { + driver: EngineDriver + container: string + publicKey: string + hostAlias: string +}): Promise { + const { driver, container, publicKey, hostAlias } = opts + + // Seed a durable host key first so the known_hosts pin survives recreates and a + // tmpfs-backed ~/.ssh (otherwise every attach is a fresh trust-on-first-use). + await ensureDurableHostKey(driver, container, hostAlias) + + // Ensure ~/.ssh + a host key exist (a readonly-os tmpfs over ~/.ssh starts empty), + // then append the key only if absent. `key=$(cat)` reads it from stdin → no injection. + const install = [ + 'sh', + '-c', + 'set -e; ' + + 'mkdir -p "$HOME/.ssh"; chmod 700 "$HOME/.ssh"; ' + + `[ -f "${HOST_KEY}" ] || ssh-keygen -q -t ed25519 -N "" -f "${HOST_KEY}"; ` + + 'touch "$HOME/.ssh/authorized_keys"; chmod 600 "$HOME/.ssh/authorized_keys"; ' + + 'key=$(cat); ' + + 'grep -qxF "$key" "$HOME/.ssh/authorized_keys" || printf "%s\\n" "$key" >> "$HOME/.ssh/authorized_keys"', + ] + const res = await driver.execCapture( + { container, cmd: install, interactive: true, tty: false, user: CONTAINER_USER }, + `${publicKey}\n`, + ) + if (res.code !== 0) { + throw new DcwError( + `Failed to install attach key into '${container}': ${res.stderr.trim() || `exit ${res.code}`}. ` + + 'Was the image built with SSH support? Rebuild with `dcw build` (SSH is on unless created with --no-ssh).', + ) + } + + await pinHostKey(driver, container, hostAlias) +} + +/** + * Start a background sshd inside the container for published-port (`--port`) mode. + * Runs as vscode on the container's SSH port; idempotent (kills a prior dcw daemon + * first). `setsid` + detached stdio lets it outlive the exec that launched it. + */ +export async function startSshDaemon(driver: EngineDriver, container: string): Promise { + // Stop a prior daemon via its pidfile, not `pkill -f` — a -f pattern matching the + // sshd invocation would also match this very `sh -c` (self-kill → exit 143). + const cmd = [ + 'sh', + '-c', + 'pid=/home/vscode/.ssh/sshd.pid; [ -f "$pid" ] && kill "$(cat "$pid")" 2>/dev/null || true; ' + + `setsid /usr/sbin/sshd -D -f ${SSHD_CONFIG_PATH} -p ${SSH_CONTAINER_PORT} ` + + '/dev/null 2>&1 & echo started', + ] + const res = await driver.execCapture({ container, cmd, interactive: false, tty: false, user: CONTAINER_USER }) + if (res.code !== 0) { + throw new DcwError(`Failed to start SSH daemon in '${container}': ${res.stderr.trim() || `exit ${res.code}`}.`) + } +} + +/** + * Give the container a stable SSH host identity. If we already persisted a host + * key for this alias, push it back in; otherwise let the container generate one, + * read it back, and persist it host-side (0600) for next time. Best-effort — on + * failure we fall back to the install shell's own keygen + accept-new pinning. + */ +async function ensureDurableHostKey(driver: EngineDriver, container: string, alias: string): Promise { + const stored = hostKeyPath(alias) + let material: string | null = null + try { + material = await fs.readFile(stored, 'utf8') + } catch { + // no persisted key yet + } + + if (material) { + // Re-inject the persisted private key; derive the matching public key from it. + const cmd = [ + 'sh', + '-c', + 'set -e; mkdir -p "$HOME/.ssh"; chmod 700 "$HOME/.ssh"; ' + + `cat > "${HOST_KEY}"; chmod 600 "${HOST_KEY}"; ssh-keygen -y -f "${HOST_KEY}" > "${HOST_KEY_PUB}"`, + ] + await driver.execCapture({ container, cmd, interactive: true, tty: false, user: CONTAINER_USER }, material) + return + } + + // First run: ensure a key exists in the container, then capture it to persist. + const cmd = [ + 'sh', + '-c', + 'set -e; mkdir -p "$HOME/.ssh"; chmod 700 "$HOME/.ssh"; ' + + `[ -f "${HOST_KEY}" ] || ssh-keygen -q -t ed25519 -N "" -f "${HOST_KEY}"; cat "${HOST_KEY}"`, + ] + const res = await driver.execCapture({ container, cmd, interactive: false, tty: false, user: CONTAINER_USER }) + if (res.code !== 0 || !res.stdout.trim()) return // best-effort + + await fs.mkdir(path.dirname(stored), { recursive: true, mode: 0o700 }) + await fs.writeFile(stored, res.stdout, { mode: 0o600 }) +} + +/** Read the container's SSH host public key and pin it in dcw's known_hosts for `alias`. */ +async function pinHostKey(driver: EngineDriver, container: string, alias: string): Promise { + const res = await driver.execCapture({ + container, + cmd: ['cat', HOST_KEY_PUB], + interactive: false, + tty: false, + user: CONTAINER_USER, + }) + if (res.code !== 0) return // best-effort; accept-new in ssh config will pin on first use + + // A host pubkey file is " [comment]"; known_hosts wants " ". + const parts = res.stdout.trim().split(/\s+/) + if (parts.length < 2) return + const line = `${alias} ${parts[0]} ${parts[1]}\n` + + const file = knownHostsPath() + await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 }) + let existing = '' + try { + existing = await fs.readFile(file, 'utf8') + } catch { + // no known_hosts yet + } + const kept = existing + .split('\n') + .filter((l) => l.trim() && !l.startsWith(`${alias} `)) + .join('\n') + const next = (kept ? `${kept}\n` : '') + line + await fs.writeFile(file, next, { mode: 0o600 }) +} diff --git a/packages/core/src/core/ssh/ssh-config.ts b/packages/core/src/core/ssh/ssh-config.ts new file mode 100644 index 0000000..d69004b --- /dev/null +++ b/packages/core/src/core/ssh/ssh-config.ts @@ -0,0 +1,108 @@ +import * as fs from 'node:fs/promises' +import os from 'node:os' +import * as path from 'node:path' + +/** SSH host alias dcw writes for an environment (also the container/host-key id). */ +export function hostAlias(name: string): string { + return `dcw-${name}` +} + +function sshConfigPath(): string { + return path.join(os.homedir(), '.ssh', 'config') +} + +export interface SshConfigEntry { + name: string + /** 'exec' → ProxyCommand over engine exec (no ports); 'port' → published TCP port. */ + mode: 'exec' | 'port' + identityFile: string + knownHostsFile: string + /** Command ssh runs as ProxyCommand (exec mode), e.g. `dcw ssh-proxy my-env`. */ + proxyCommand?: string + /** Published host port (port mode). */ + port?: number +} + +/** Build the managed `Host` block (without markers) for an entry. */ +export function renderEntry(entry: SshConfigEntry): string { + const alias = hostAlias(entry.name) + const lines = [`Host ${alias}`, ' User vscode'] + if (entry.mode === 'exec') { + lines.push(` ProxyCommand ${entry.proxyCommand}`) + } else { + lines.push(' HostName localhost', ` Port ${entry.port ?? 2222}`) + } + lines.push( + ` IdentityFile ${entry.identityFile}`, + ' IdentitiesOnly yes', + ` UserKnownHostsFile ${entry.knownHostsFile}`, + ' StrictHostKeyChecking accept-new', + ) + return lines.join('\n') +} + +/** + * Insert or replace dcw's managed block for an environment in ~/.ssh/config, + * delimited by `# >>> dcw >>>` / `# <<< dcw <<<` markers. Idempotent: + * writing the same entry twice yields a single block. Returns the host alias. + */ +export async function writeSshConfig(entry: SshConfigEntry): Promise { + const file = sshConfigPath() + await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 }) + + let existing = '' + try { + existing = await fs.readFile(file, 'utf8') + } catch { + // no config yet + } + + const block = mergeBlock(existing, entry.name, renderEntry(entry)) + await fs.writeFile(file, block, { mode: 0o600 }) + return hostAlias(entry.name) +} + +/** Remove dcw's managed block for an environment from ~/.ssh/config (no-op if absent). */ +export async function removeSshConfig(name: string): Promise { + const file = sshConfigPath() + let existing: string + try { + existing = await fs.readFile(file, 'utf8') + } catch { + return + } + const next = stripBlock(existing, name) + if (next !== existing) await fs.writeFile(file, next, { mode: 0o600 }) +} + +function markers(name: string): { begin: string; end: string } { + return { begin: `# >>> dcw ${name} >>>`, end: `# <<< dcw ${name} <<<` } +} + +function stripBlock(content: string, name: string): string { + const { begin, end } = markers(name) + const lines = content.split('\n') + const out: string[] = [] + let skipping = false + for (const line of lines) { + if (line.trim() === begin) { + skipping = true + continue + } + if (skipping) { + if (line.trim() === end) skipping = false + continue + } + out.push(line) + } + // Collapse any blank gap left behind and trim trailing blank lines. + return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '') +} + +function mergeBlock(content: string, name: string, body: string): string { + const { begin, end } = markers(name) + const base = stripBlock(content, name) + const managed = `${begin}\n${body}\n${end}` + if (!base.trim()) return `${managed}\n` + return `${base}\n\n${managed}\n` +} diff --git a/packages/core/src/core/up-pipeline.ts b/packages/core/src/core/up-pipeline.ts new file mode 100644 index 0000000..cd4f4ab --- /dev/null +++ b/packages/core/src/core/up-pipeline.ts @@ -0,0 +1,109 @@ +import { DcwError } from '../errors.js' +import type { EngineCapabilities, EngineDriver, EngineName, RunSpec } from '../engine/types.js' +import { enforceStrict, translate, type TranslateResult } from '../hardening/translator.js' +import type { EnvManifest } from '../state/manifest.js' +import { isEphemeralWorkspace, type ResolvedPlan } from './plan.js' + +export const ENV_LABEL = 'dcw.env' + +export interface UpOptions { + manifest: EnvManifest + plan: ResolvedPlan + driver: EngineDriver + capabilities: EngineCapabilities + engineName: EngineName + /** Host directory bind-mounted at /workspace (ignored for ephemeral workspaces). */ + workspaceDir: string + strict?: boolean + /** Publish the container's SSH port (2222) to this host port for `dcw attach --port`. */ + sshPublishPort?: number + now: string +} + +/** True when the plan air-gaps the container (`network-none`), which blocks published-port SSH. */ +export function planHasNetworkNone(plan: ResolvedPlan): boolean { + return plan.effects.some((e) => e.kind === 'network-none') +} + +/** Container port the in-image sshd listens on in published-port (`--port`) mode. */ +export const SSH_CONTAINER_PORT = 2222 + +/** Host interface `dcw attach --port` publishes sshd on. Loopback only, never 0.0.0.0. */ +export const SSH_PUBLISH_HOST = '127.0.0.1' + +export interface UpOutcome { + manifest: EnvManifest + containerId: string + translation: TranslateResult + runSpec: RunSpec +} + +export function containerName(name: string): string { + return `dcw-${name}` +} + +/** + * Start a hardened, detached container for an already-built environment. + * Translates hardening to engine-correct flags (degrading per capabilities), + * adds the workspace mount, runs, and records container state on the manifest. + */ +export async function upEnvironment(opts: UpOptions): Promise { + const { manifest, plan, driver, capabilities, engineName, now } = opts + + if (!manifest.image?.tag) { + throw new DcwError(`Environment '${manifest.name}' has no built image. Run 'dcw build ${manifest.name}' first.`) + } + + const translation = translate(plan.effects, capabilities, engineName) + if (opts.strict) enforceStrict(translation) + + const flags = [...translation.flags] + if (!isEphemeralWorkspace(plan)) { + flags.push('-v', `${opts.workspaceDir}:/workspace`) + } + + if (opts.sshPublishPort !== undefined) { + if (planHasNetworkNone(plan)) { + throw new DcwError( + `Cannot publish an SSH port: '${manifest.name}' is hardened with network-none. ` + + 'Attach over the default (no-port) exec proxy instead — run `dcw attach` without --port.', + ) + } + // Bind to loopback explicitly: a bare `-p :` makes Docker/Podman + // listen on 0.0.0.0, publishing an SSH server on an untrusted-code container + // to every interface (LAN/VPN). `dcw attach` is a local-editor workflow, and + // findFreePort() already probes 127.0.0.1, so loopback is the intended scope. + flags.push('-p', `${SSH_PUBLISH_HOST}:${opts.sshPublishPort}:${SSH_CONTAINER_PORT}`) + } + + const runSpec: RunSpec = { + image: manifest.image.tag, + name: containerName(manifest.name), + labels: { [ENV_LABEL]: manifest.name }, + flags, + workdir: '/workspace', + detach: true, + // Keep the container alive so the user can `exec` into it (shell-first). + command: ['sleep', 'infinity'], + } + + const { containerId } = await driver.run(runSpec) + + const updated: EnvManifest = { + ...manifest, + engine: engineName, + container: { + id: containerId, + name: runSpec.name, + status: 'running', + startedAt: now, + appliedFlags: flags, + droppedHardening: translation.dropped.map((e) => e.kind), + unenforcedHardening: translation.unenforced.map((e) => e.kind), + ssh: opts.sshPublishPort !== undefined ? { mode: 'port', port: opts.sshPublishPort } : undefined, + }, + updatedAt: now, + } + + return { manifest: updated, containerId, translation, runSpec } +} diff --git a/packages/core/src/core/wizard/coreLanguages.ts b/packages/core/src/core/wizard/coreLanguages.ts deleted file mode 100644 index e98a925..0000000 --- a/packages/core/src/core/wizard/coreLanguages.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { checkboxWithTopDescription } from "@/ui/components/checkboxWithTopDescription"; - -export async function coreLanguages(state: {coreLanguages?: string[]}) { - return await checkboxWithTopDescription({ - message: 'Select core programming languages', - choices: [ - { name: 'Rust', value: 'rust', description: 'Rust compiler and cargo package manager', checked: state.coreLanguages?.includes("rust") }, - { name: 'Python', value: 'python', description: 'Python runtime with pip and uv package managers', checked: state.coreLanguages?.includes("python") }, - { name: 'Go', value: 'go', description: 'Go programming language with asdf version manager', checked: state.coreLanguages?.includes("go") }, - { name: 'Node.js', value: 'node', description: 'Node.js runtime with pnpm package manager', checked: state.coreLanguages?.includes("node") } - ], - footer: { - back: true, - exit: true, - }, - allowBack: true, - }); -} diff --git a/packages/core/src/core/wizard/devcontainerName.ts b/packages/core/src/core/wizard/devcontainerName.ts deleted file mode 100644 index 32b608a..0000000 --- a/packages/core/src/core/wizard/devcontainerName.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { inputWithSymbols as input } from "@/ui/components"; -import * as path from "node:path"; - -export async function devcontainerName(state: {name?: string}): Promise { - - let defaultName = path.basename(process.cwd()); - - if (state.name) { - defaultName = state.name; - } - - if (!defaultName || defaultName.trim().length === 0 || /^[.\s]+$/.test(defaultName)) { - defaultName = "Devcontainer"; - } - - const name = await input({ - message: "Name your devcontainer:", - default: defaultName, - validate: (value: string) => { - const trimmed = (value ?? "").trim(); - if (trimmed.length === 0) return "Name cannot be empty"; - if (trimmed.length > 80) return "Name is too long (max 80 chars)"; - return true; - }, - footer: { - back: false, - exit: true, - }, - allowBack: false, - }); - return name.trim(); -} diff --git a/packages/core/src/core/wizard/frameworks.ts b/packages/core/src/core/wizard/frameworks.ts deleted file mode 100644 index 30126a0..0000000 --- a/packages/core/src/core/wizard/frameworks.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { checkboxWithTopDescription } from "@/ui/components/checkboxWithTopDescription" - -export async function frameworks(state: {frameworks?: string[]}) { - return await checkboxWithTopDescription({ - message: "Select frameworks", - choices: [ - { name: "Foundry", value: "foundry", description: "Automatically installs: Rust", checked: state.frameworks?.includes("foundry") }, - { name: "Hardhat", value: "hardhat", description: "Automatically installs: Node.js, pnpm (package manager)", checked: state.frameworks?.includes("hardhat") }, - { name: "Ape (ApeWorX)", value: "ape", description: "Automatically installs: Python, uv (package manager)", checked: state.frameworks?.includes("ape") }, - ], - footer: { - back: true, - exit: true, - }, - allowBack: true, - }); -} diff --git a/packages/core/src/core/wizard/fuzzingTesting.ts b/packages/core/src/core/wizard/fuzzingTesting.ts deleted file mode 100644 index 2392824..0000000 --- a/packages/core/src/core/wizard/fuzzingTesting.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { checkboxWithTopDescription } from "@/ui/components/checkboxWithTopDescription"; - -export async function fuzzingAndTesting(state: {fuzzingAndTesting?: string[]}) { - return await checkboxWithTopDescription ({ - message: "Select fuzzing and testing tools", - choices: [ - { name: "Echidna", value: "echidna", description: "Automatically installs: Golang, asdf (package manager)", checked: state.fuzzingAndTesting?.includes("echidna") }, - { name: "Medusa", value: "medusa", description: "Automatically installs: Golang, asdf (package manager)", checked: state.fuzzingAndTesting?.includes("medusa") }, - { name: "Halmos", value: "halmos", description: "Automatically installs: Python, uv (package manager)", checked: state.fuzzingAndTesting?.includes("halmos") }, - { name: "Ityfuzz", value: "ityfuzz", description: "Automatically installs: Rust", checked: state.fuzzingAndTesting?.includes("ityfuzz") }, - { name: "Aderyn", value: "aderyn", description: "Automatically installs: Rust", checked: state.fuzzingAndTesting?.includes("aderyn") } - ], - footer: { - back: true, - exit: true, - }, - allowBack: true, - }); -} diff --git a/packages/core/src/core/wizard/gitClone.ts b/packages/core/src/core/wizard/gitClone.ts deleted file mode 100644 index de6f018..0000000 --- a/packages/core/src/core/wizard/gitClone.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { confirmWithFooter as confirm, inputWithSymbols as input } from '@/ui/components' - -export interface GitRepositoryConfig { - url: string - branch?: string - enabled: boolean -} - -export async function gitClone(state: {gitClone?: boolean}): Promise { - const shouldClone = await confirm({ - message: 'Would you like to automatically clone a git repository during build?', - default: state.gitClone ?? false, - footer: { - back: true, - exit: true, - }, - allowBack: true - }) - - if ((shouldClone as any) === Symbol.for('back')) { - return Symbol.for('back') as any; - } - - if (!shouldClone) { - return { - url: '', - enabled: false - } - } - - const repoUrl = await input({ - message: 'Enter the git repository URL to clone:', - validate: (input: string) => { - if (!input.trim()) { - return 'Repository URL cannot be empty' - } - - // Basic URL validation for git repositories - const gitUrlPattern = /^(https:\/\/|git@|ssh:\/\/|git:\/\/)/ - if (!gitUrlPattern.test(input.trim())) { - return 'Please enter a valid git repository URL (https://, git@, ssh://, or git://)' - } - - return true - } - }) - - const shouldSpecifyBranch = await confirm({ - message: 'Would you like to specify a specific branch/tag to clone?', - default: false - }) - - let branch: string | undefined - - if (shouldSpecifyBranch) { - branch = await input({ - message: 'Enter the branch or tag name:', - default: 'main', - validate: (input: string) => { - if (!input.trim()) { - return 'Branch/tag name cannot be empty' - } - return true - } - }) - } - - return { - url: repoUrl.trim(), - branch: branch?.trim(), - enabled: true - } -} diff --git a/packages/core/src/core/wizard/index.ts b/packages/core/src/core/wizard/index.ts deleted file mode 100644 index c9725ac..0000000 --- a/packages/core/src/core/wizard/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { coreLanguages } from "./coreLanguages"; -export { languages } from "./languages"; -export { frameworks } from "./frameworks"; -export { fuzzingAndTesting } from "./fuzzingTesting"; -export { securityTooling } from "./securityTooling"; -export { systemHardening } from "./systemHardening"; -export { securityProfiles, recipesToSecurityHardening, getRecipeInfo } from "./securityProfiles"; -export { vscodeExtensions } from "./vscodeExtensions"; -export { savePath } from "./savePath"; -export { devcontainerName } from "./devcontainerName"; -export { gitClone } from "./gitClone"; -export { wizard } from "./wizard"; \ No newline at end of file diff --git a/packages/core/src/core/wizard/languages.ts b/packages/core/src/core/wizard/languages.ts deleted file mode 100644 index bd45560..0000000 --- a/packages/core/src/core/wizard/languages.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { checkboxWithTopDescription } from "@/ui/components/checkboxWithTopDescription"; - -export async function languages(state: {languages?: string[]}) { - return await checkboxWithTopDescription({ - message: 'Select smart contract languages', - choices: [ - { name: 'Solidity', value: 'solidity', description: 'Automatically installs: solc, solc-select', checked: state.languages?.includes("solidity") }, - { name: 'Vyper', value: 'vyper', description: 'Automatically installs: python, python3-dev, libpython3-dev, uv', checked: state.languages?.includes("vyper") } - ], - footer: { - back: true, - exit: true, - }, - allowBack: true, - }); -} \ No newline at end of file diff --git a/packages/core/src/core/wizard/savePath.ts b/packages/core/src/core/wizard/savePath.ts deleted file mode 100644 index 38c3885..0000000 --- a/packages/core/src/core/wizard/savePath.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { inputWithSymbols as input } from "@/ui/components"; -import * as path from "node:path"; -import * as fs from "node:fs/promises"; - -export async function savePath(): Promise { - const answer = await input({ - message: "Select a directory to save the devcontainer files", - default: process.cwd(), - validate: async (value: string) => { - try { - const resolved = path.resolve(value.trim() || "."); - const stats = await fs.stat(resolved).catch(() => undefined); - if (!stats) { - return true; - } - if (!stats.isDirectory()) { - return "Path exists but is not a directory"; - } - return true; - } catch (e) { - return "Invalid path"; - } - }, - footer: { - back: true, - exit: true, - }, - allowBack: true - }); - - if ((answer as any) === Symbol.for('back')) { - return Symbol.for('back') as any; - } - - return path.resolve(answer.trim() || "."); -} diff --git a/packages/core/src/core/wizard/securityProfiles.ts b/packages/core/src/core/wizard/securityProfiles.ts deleted file mode 100644 index 83b4119..0000000 --- a/packages/core/src/core/wizard/securityProfiles.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { Separator } from "@inquirer/core"; -import { selectWithTopDescription } from "@/ui/components/selectWithTopDescription"; - -// Recipe to security hardening mapping -const RECIPE_MAPPINGS = { - "network-restricted-analysis": { - description: "For web APIs, git, and package installs without packet crafting capabilities.", - caveat: "Packet-crafting tools will not work.", - choices: [ - "ephemeral-workspace", - "secure-tmp", - "drop-caps", - "no-new-privs", - "apparmor", - "no-raw-packets", - "secure-dns" - ] - }, - "ci-like-local-runner": { - description: "Mirrors CI behavior locally with immutable file system.", - caveat: "Cache writes will not persist across runs.", - choices: [ - "readonly-os", - "ephemeral-workspace", - "secure-tmp", - "drop-caps", - "no-new-privs", - "apparmor", - "secure-dns" - ] - }, - "package-install-session": { - description: "Allows installing packages while keeping guardrails in place.", - caveat: "Omit drop-caps if installs fail unexpectedly.", - choices: [ - "ephemeral-workspace", - "secure-tmp", - "no-new-privs", - "apparmor", - "secure-dns", - "vscode-security" - ] - }, - "security-research-controlled-net": { - description: "For API testing and collectors without packet crafting capability.", - caveat: "Packet-crafting tools will not work.", - choices: [ - "ephemeral-workspace", - "secure-tmp", - "drop-caps", - "no-new-privs", - "apparmor", - "no-raw-packets", - "secure-dns" - ] - }, - "development": { - description: "Balanced security for daily development work", - caveat: "Standard development environment with basic security hardening.", - choices: [ - "secure-tmp", - "no-new-privs", - "apparmor", - "secure-dns", - "vscode-security" - ] - }, - "hardened": { - description: "Enhanced security for smart contract auditing and security research", - caveat: "Packet-crafting tools will not work due to no-raw-packets restriction.", - choices: [ - "ephemeral-workspace", - "secure-tmp", - "drop-caps", - "no-new-privs", - "apparmor", - "no-raw-packets", - "secure-dns", - "vscode-security" - ] - }, - "airgapped": { - description: "Enhanced security for smart contract auditing and security research", - caveat: "VS Code extensions will not be installed.", - choices: [ - "ephemeral-workspace", - "secure-tmp", - "drop-caps", - "no-new-privs", - "apparmor", - "no-raw-packets", - "secure-dns", - "vscode-security", - "network-none", - ] - }, - "paranoid": { - description: "Maximum security with air-gapped environment", - caveat: "No network access or persistent storage - extensions and package managers will not work.", - choices: [ - "readonly-os", - "ephemeral-workspace", - "secure-tmp", - "drop-caps", - "no-new-privs", - "apparmor", - "network-none", - "vscode-security" - ] - } -} as const; - -export async function securityProfiles() { - return await selectWithTopDescription({ - message: "Select one security profile:", - loop: false, - choices: [ - { - name: "Development", - value: "development", - description: RECIPE_MAPPINGS["development"].description, - caveat: RECIPE_MAPPINGS["development"].caveat - }, - { - name: "Hardened", - value: "hardened", - description: RECIPE_MAPPINGS["hardened"].description, - caveat: RECIPE_MAPPINGS["hardened"].caveat - }, - { - name: "Air-gapped", - value: "airgapped", - description: RECIPE_MAPPINGS["airgapped"].description, - caveat: RECIPE_MAPPINGS["airgapped"].caveat - }, - new Separator("——— Experimental Profiles ———"), - { - name: "Paranoid", - value: "paranoid", - description: RECIPE_MAPPINGS["paranoid"].description, - caveat: RECIPE_MAPPINGS["paranoid"].caveat - }, - { - name: "Network Restricted Analysis", - value: "network-restricted-analysis", - description: RECIPE_MAPPINGS["network-restricted-analysis"].description, - caveat: RECIPE_MAPPINGS["network-restricted-analysis"].caveat - }, - { - name: "CI-like Local Runner", - value: "ci-like-local-runner", - description: RECIPE_MAPPINGS["ci-like-local-runner"].description, - caveat: RECIPE_MAPPINGS["ci-like-local-runner"].caveat - }, - { - name: "Package Install Session", - value: "package-install-session", - description: RECIPE_MAPPINGS["package-install-session"].description, - caveat: RECIPE_MAPPINGS["package-install-session"].caveat - }, - { - name: "Security Research (Controlled Net)", - value: "security-research-controlled-net", - description: RECIPE_MAPPINGS["security-research-controlled-net"].description, - caveat: RECIPE_MAPPINGS["security-research-controlled-net"].caveat - } - ], - }); -} - -export function recipesToSecurityHardening(selectedRecipes: string[]): string[] { - const hardeningChoices = new Set(); - - for (const recipe of selectedRecipes) { - if (recipe in RECIPE_MAPPINGS) { - const recipeMapping = RECIPE_MAPPINGS[recipe as keyof typeof RECIPE_MAPPINGS]; - recipeMapping.choices.forEach(choice => { - if (choice) { - hardeningChoices.add(choice); - } - }); - } - } - - return Array.from(hardeningChoices); -} - -export function getRecipeInfo(recipeKey: string): { description: string; caveat: string } | null { - if (recipeKey in RECIPE_MAPPINGS) { - const recipe = RECIPE_MAPPINGS[recipeKey as keyof typeof RECIPE_MAPPINGS]; - return { - description: recipe.description, - caveat: recipe.caveat - }; - } - return null; -} diff --git a/packages/core/src/core/wizard/securityTooling.ts b/packages/core/src/core/wizard/securityTooling.ts deleted file mode 100644 index 252d3c7..0000000 --- a/packages/core/src/core/wizard/securityTooling.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { checkboxWithTopDescription } from "@/ui/components/checkboxWithTopDescription"; - -export async function securityTooling(state: {securityTooling?: string[]}) { - return await checkboxWithTopDescription({ - message: "Select security tooling", - choices: [ - { name: "Slither", value: "slither", description: "Automatically installs: Python, uv (package manager)", checked: state.securityTooling?.includes("slither") }, - { name: "Mythril", value: "mythril", description: "Automatically installs: Python, uv (package manager)", checked: state.securityTooling?.includes("mythril") }, - { name: "Crytic", value: "crytic-compile", description: "Automatically installs: Python, uv (package manager)", checked: state.securityTooling?.includes("crytic-compile") }, - { name: "Panoramix", value: "panoramix", description: "Automatically installs: Python, uv (package manager), Panoramix VS Code extension", checked: state.securityTooling?.includes("panoramix") }, - { name: "Semgrep", value: "semgrep", description: "Automatically installs: Python, uv (package manager)", checked: state.securityTooling?.includes("semgrep") }, - { name: "Heimdall", value: "heimdall", description: "Automatically installs: Rust", checked: state.securityTooling?.includes("heimdall") } - ], - footer: { - back: true, - exit: true, - }, - allowBack: true, - }); -} diff --git a/packages/core/src/core/wizard/systemHardening.ts b/packages/core/src/core/wizard/systemHardening.ts deleted file mode 100644 index 9bfaac0..0000000 --- a/packages/core/src/core/wizard/systemHardening.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { checkboxWithTopDescription, selectWithTopDescription } from "@/ui/components/"; -import { ui } from "@/ui/styling/ui"; - -export async function systemHardening(state: {systemHardening?: string[]}) { - ui.clearScreen(); - console.log(ui.header()); - console.log(''); - const selectedOptions: string[] = []; - - // File System Security - const fsOptions = await checkboxWithTopDescription({ - message: "File System Security", - loop: false, - choices: [ - { name: "(EXPERIMENTAL) Read-only file system", value: "readonly-os", description: "Mounts the file system as read-only, some tools will not work" }, - { name: "Secure temp directories", value: "secure-tmp", description: "Creates temp dirs with noexec, nosuid flags" }, - { name: "Ephemeral workspace", value: "ephemeral-workspace", description: "Uses tmpf mount to create a ephemeral workpsace"} - ], - default: state.systemHardening, - footer: { - back: false, - exit: true, - }, - }); - selectedOptions.push(...fsOptions); - - ui.clearScreen(); - console.log(ui.header()); - console.log(''); - - - const containerSecurity = await checkboxWithTopDescription({ - message: "Container Security", - loop: false, - choices: [ - { name: "Drop all capabilities", value: "drop-caps", description: "Removes all Linux capabilities from container" }, - { name: "No new privileges", value: "no-new-privs", description: "Prevents privilege escalation through SUID/SGID" }, - { name: "AppArmor profile", value: "apparmor", description: "Applies Docker's default AppArmor MAC profile" }, - ], - footer: { - exit: true, - }, - }); - selectedOptions.push(...containerSecurity); - - ui.clearScreen(); - console.log(ui.header()); - console.log(''); - - const networkIsolation = await selectWithTopDescription({ - message: "Network Configuration", - loop: false, - choices: [ - { name: "Normal networking", value: "normal", description: "Standard container networking" }, - { name: "Enhanced DNS security", value: "secure-dns", description: "Forces Cloudflare DNS (1.1.1.1, 1.0.0.1)" }, - { name: "Complete network isolation", value: "network-none", description: "Isolates container from network, VS Code extensions will not be installed" }, - ], - footer: { - exit: true, - }, - }); - - if (networkIsolation === "secure-dns") { - ui.clearScreen(); - console.log(ui.header()); - console.log(''); - selectedOptions.push("secure-dns"); - - // Additional network security options - const additionalNetworkSecurity = await checkboxWithTopDescription({ - message: "Additional Network Security (compatible with DNS)", - loop: false, - choices: [ - { name: "Disable IPv6", value: "disable-ipv6", description: "Disables IPv6 networking to reduce attack surface" }, - { name: "Disable raw packets", value: "no-raw-packets", description: "Drops NET_RAW capability to prevent packet crafting" }, - ], - }); - selectedOptions.push(...additionalNetworkSecurity); - } else if (networkIsolation === "network-none") { - selectedOptions.push("network-none"); - } - - ui.clearScreen(); - console.log(ui.header()); - console.log(''); - - // Application Security - const appSecurity = await checkboxWithTopDescription ({ - message: "Application Security", - loop: false, - choices: [ - { name: "VS Code security", value: "vscode-security", description: "Disables auto-tasks, workspace trust, and telemetry" }, - ], - footer: { - back: false, - exit: true, - }, - }); - selectedOptions.push(...appSecurity); - - ui.clearScreen(); - console.log(ui.header()); - console.log(''); - - // Resource Limits - const resourceLimits = await selectWithTopDescription({ - message: "Resource Limits", - loop: false, - choices: [ - { name: "No limits", value: "none", description: "No resource constraints" }, - { name: "Light (512MB, 2 cores)", value: "resource-limits", description: "Suitable for simple development" }, - { name: "Medium (2GB, 4 cores)", value: "resource-limits-medium", description: "Better for Node.js, Rust, Java projects" }, - { name: "Heavy (4GB, 8 cores)", value: "resource-limits-heavy", description: "For ML, large builds, or complex projects" }, - ], - footer: { - back: false, - exit: true, - }, - }); - if (resourceLimits !== "none") { - selectedOptions.push(resourceLimits); - } - - return selectedOptions; -} diff --git a/packages/core/src/core/wizard/vscodeExtensions.ts b/packages/core/src/core/wizard/vscodeExtensions.ts deleted file mode 100644 index eb843e2..0000000 --- a/packages/core/src/core/wizard/vscodeExtensions.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { checkboxWithTopDescription } from "@/ui/components/checkboxWithTopDescription"; -import { confirmWithFooter as confirm } from "@/ui/components"; -import { Separator } from "@inquirer/prompts"; - -type VscodeExtension = { id: string; name: string }; - -const tintinExtensions: VscodeExtension[] = [ - { id: "tintinweb.ethereum-security-bundle", name: "Tintin's Ethereum Security Bundle" }, - { id: "tintinweb.vscode-ethover", name: "Tintin's EthOver" }, - { id: "trailofbits.weaudit", name: "WeAudit" }, - { id: "tintinweb.vscode-inline-bookmarks", name: "Tintin's Inline Bookmarks" }, - { id: "tintinweb.vscode-solidity-language", name: "Tintin's Solidity Language Tools" }, - { id: "tintinweb.graphviz-interactive-preview", name: "Tintin's Graphviz Interactive Preview" }, - { id: "trailofbits.contract-explorer", name: "Trail of Bits Contract Explorer" }, - { id: "tintinweb.vscode-decompiler", name: "Tintin's Smart Contract Decompiler" }, -]; - -const NomicFoundation: VscodeExtension[] = [ - { id: "NomicFoundation.hardhat-solidity", name: "Nomic's Solidity" }, -] - -const Olympix: VscodeExtension[] = [ - { id: "Olympixai.olympix", name: "Olympix AI" }, -] - -export async function vscodeExtensions(state: {vscodeExtensions?: string[]}): Promise { - const autoInstall = await confirm({ - message: "Do you want to automatically install recommended VS Code extensions?", - default: state.vscodeExtensions ?? true, - footer: { - back: true, - exit: true, - }, - allowBack: true - }); - - if (autoInstall === Symbol.for('back')) { - return Symbol.for('back') as any; - } - - if (autoInstall) { - return tintinExtensions.map((ext) => ext.id); - } - - const selected = await checkboxWithTopDescription({ - message: "Select VS Code extensions to install", - loop: false, - choices: [ - new Separator("Tintin's Extensions"), - ...tintinExtensions.map((ext) => ({ name: ext.name, value: ext.id })), - new Separator("Nomic Foundation"), - ...NomicFoundation.map((ext) => ({ name: ext.name, value: ext.id })), - new Separator("Olympix"), - ...Olympix.map((ext) => ({ name: ext.name, value: ext.id })), - ], - }); - - return selected as string[]; -} diff --git a/packages/core/src/core/wizard/wizard.ts b/packages/core/src/core/wizard/wizard.ts deleted file mode 100644 index 5d47464..0000000 --- a/packages/core/src/core/wizard/wizard.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { - coreLanguages, - languages, - frameworks, - fuzzingAndTesting, - securityTooling, - systemHardening, - securityProfiles, - recipesToSecurityHardening, - vscodeExtensions, - savePath, - devcontainerName, - gitClone, -} from '@/core/wizard' -import type { WizardState } from '@/types' -import { selectWithTopDescription } from "@/ui/components/selectWithTopDescription"; -import { colorize } from "@/ui/styling/colors"; -import { symbols } from "@/ui/styling/symbols"; -import { ui } from "@/ui/styling/ui"; - -const BACK = Symbol.for('back'); -export async function wizard(args: { name?: string }) { - - const wizardState: WizardState = {} - - const steps = [ - async () => { - const result = args.name !== undefined ? args.name : await devcontainerName({ name: wizardState.name }); - if ((result as any) !== BACK) wizardState.name = result as string; - return result; - }, - async () => { - const result = await coreLanguages({ coreLanguages: wizardState.coreLanguages }); - if ((result as any) !== BACK) wizardState.coreLanguages = result as string[]; - return result; - }, - async () => { - const result = await languages({ languages: wizardState.languages }); - if ((result as any) !== BACK) wizardState.languages = result as string[]; - return result; - }, - async () => { - const result = await frameworks({ frameworks: wizardState.frameworks }); - if ((result as any) !== BACK) wizardState.frameworks = result as string[]; - return result; - }, - async () => { - const result = await fuzzingAndTesting({ fuzzingAndTesting: wizardState.fuzzingAndTesting }); - if ((result as any) !== BACK) wizardState.fuzzingAndTesting = result as string[]; - return result; - }, - async () => { - const result = await securityTooling({ securityTooling: wizardState.securityTooling }); - if ((result as any) !== BACK) wizardState.securityTooling = result as string[]; - return result; - }, - async () => { - const hardeningMethod = await selectWithTopDescription({ - message: 'How would you like to configure security hardening?', - choices: [ - { - name: 'Use security profiles', - value: 'profiles', - description: 'Choose from predefined security configurations' - }, - { - name: 'Manual selection', - value: 'manual', - description: 'Manually select individual security hardening options' - }, - ], - footer: { - back: true, - exit: true, - }, - allowBack: true, - }) - - if (hardeningMethod === BACK) { - return BACK; - } - - if (hardeningMethod === 'profiles') { - const selectedProfile = await securityProfiles() - if (selectedProfile === BACK) return BACK; - - wizardState.systemHardening = recipesToSecurityHardening([selectedProfile]) - - if (wizardState.systemHardening.length > 0) { - console.log(` -${colorize.brand(`${symbols.diamond} Security hardening options automatically selected from profiles:`)}`) - wizardState.systemHardening.forEach(option => { - console.log(` ${colorize.success(symbols.check)} ${colorize.brand(option)}`) - }) - console.log('') - } - return selectedProfile; - } else { - const result = await systemHardening({ systemHardening: wizardState.systemHardening }) - if ((result as any) !== BACK) wizardState.systemHardening = result as string[]; - return result; - } - }, - async () => { - const result = await vscodeExtensions({ vscodeExtensions: wizardState.vscodeExtensions }); - if ((result as any) !== BACK) wizardState.vscodeExtensions = result as string[]; - return result; - }, - async () => { - const result = await gitClone({ gitClone: wizardState.gitRepository?.enabled }); - if ((result as any) !== BACK) wizardState.gitRepository = result as any; - return result; - }, - async () => { - const result = await savePath(); - if (result !== BACK) wizardState.savePath = result as string; - return result; - }, - ]; - - let currentStep = 0; - while (currentStep < steps.length) { - ui.clearScreen(); - console.log(ui.header()); - console.log(''); - - const result = await steps[currentStep](); - - if (result === BACK) { - currentStep--; - } else { - currentStep++; - } - } - - return wizardState -} \ No newline at end of file diff --git a/packages/core/src/domain/catalog.ts b/packages/core/src/domain/catalog.ts new file mode 100644 index 0000000..9cdb3ec --- /dev/null +++ b/packages/core/src/domain/catalog.ts @@ -0,0 +1,84 @@ +/** + * Catalog of user-selectable environment options, grouped by category. + * Drives the wizard, the non-interactive flags, the `schema` command, and + * dependency resolution. Values mirror the original wizard's vocabulary. + */ + +export interface CatalogItem { + value: string + label: string + description: string +} + +export interface CatalogCategory { + key: SelectionKey + title: string + /** Whether multiple items may be selected (all current categories are multi). */ + multi: boolean + items: CatalogItem[] +} + +export type SelectionKey = + | 'coreLanguages' + | 'languages' + | 'frameworks' + | 'fuzzingAndTesting' + | 'securityTooling' + | 'aiAgents' + +export const CORE_LANGUAGES: CatalogItem[] = [ + { value: 'rust', label: 'Rust', description: 'Rust toolchain via rustup.' }, + { value: 'python', label: 'Python', description: 'Python 3.12 via uv.' }, + { value: 'go', label: 'Go', description: 'Latest Go via asdf.' }, + { value: 'node', label: 'Node.js', description: 'Node + pnpm via the devcontainers install script.' }, +] + +export const LANGUAGES: CatalogItem[] = [ + { value: 'solidity', label: 'Solidity', description: 'solc-select with multiple solc versions (pulls Python).' }, + { value: 'vyper', label: 'Vyper', description: 'Vyper compiler via uv (pulls Python).' }, +] + +export const FRAMEWORKS: CatalogItem[] = [ + { value: 'foundry', label: 'Foundry', description: 'forge/cast/anvil (pulls Rust).' }, + { value: 'hardhat', label: 'Hardhat', description: 'Hardhat dev environment (pulls Node).' }, + { value: 'ape', label: 'Ape', description: 'ApeWorX framework (pulls Python).' }, +] + +export const FUZZING_AND_TESTING: CatalogItem[] = [ + { value: 'echidna', label: 'Echidna', description: 'Property-based fuzzer (multi-stage copy).' }, + { value: 'medusa', label: 'Medusa', description: 'Parallel fuzzer (pulls Go).' }, + { value: 'halmos', label: 'Halmos', description: 'Symbolic testing (pulls Python).' }, + { value: 'ityfuzz', label: 'ItyFuzz', description: 'Snapshot-based fuzzer (pulls Rust).' }, + { value: 'aderyn', label: 'Aderyn', description: 'Cyfrin static analyzer (pulls Rust).' }, +] + +export const SECURITY_TOOLING: CatalogItem[] = [ + { value: 'slither', label: 'Slither', description: 'Static analysis framework (pulls Python).' }, + { value: 'mythril', label: 'Mythril', description: 'Symbolic execution analyzer (pulls Python).' }, + { value: 'crytic-compile', label: 'crytic-compile', description: 'Compilation helper (pulls Python).' }, + { value: 'panoramix', label: 'Panoramix', description: 'Decompiler (pulls Python).' }, + { value: 'slither-lsp', label: 'Slither LSP', description: 'Slither language server (pulls Python).' }, + { value: 'napalm-toolbox', label: 'Napalm', description: 'Napalm toolbox (pulls Python).' }, + { value: 'semgrep', label: 'Semgrep', description: 'Pattern-based static analysis (pulls Python).' }, + { value: 'slitherin', label: 'Slitherin', description: 'Extra Slither detectors (pulls Python).' }, + { value: 'heimdall', label: 'Heimdall', description: 'EVM toolkit / decompiler (pulls Rust).' }, +] + +export const AI_AGENTS: CatalogItem[] = [ + { value: 'claude', label: 'Claude Code', description: 'Anthropic Claude Code CLI (pulls Node).' }, + { value: 'codex', label: 'Codex', description: 'OpenAI Codex CLI (pulls Node).' }, + { value: 'opencode', label: 'opencode', description: 'opencode multi-provider agent CLI (pulls Node).' }, +] + +export const CATALOG: CatalogCategory[] = [ + { key: 'coreLanguages', title: 'Core languages', multi: true, items: CORE_LANGUAGES }, + { key: 'languages', title: 'Smart-contract languages', multi: true, items: LANGUAGES }, + { key: 'frameworks', title: 'Frameworks', multi: true, items: FRAMEWORKS }, + { key: 'fuzzingAndTesting', title: 'Fuzzing & testing', multi: true, items: FUZZING_AND_TESTING }, + { key: 'securityTooling', title: 'Security tooling', multi: true, items: SECURITY_TOOLING }, + { key: 'aiAgents', title: 'AI coding agents', multi: true, items: AI_AGENTS }, +] + +export function validValuesFor(key: SelectionKey): string[] { + return CATALOG.find((c) => c.key === key)?.items.map((i) => i.value) ?? [] +} diff --git a/packages/core/src/domain/dependency-resolver.ts b/packages/core/src/domain/dependency-resolver.ts new file mode 100644 index 0000000..d95cfb6 --- /dev/null +++ b/packages/core/src/domain/dependency-resolver.ts @@ -0,0 +1,98 @@ +import { isToolKey, type ToolKey } from './install-commands.js' +import type { SelectionKey } from './catalog.js' + +export type Selections = Partial> + +export interface ResolvedTools { + /** All required tool keys (incl. `python`/`rust`/`go`/`node` markers), insertion-ordered & unique. */ + all: ToolKey[] + /** Whether Python is required (installed inline by the generator). */ + needsPython: boolean + /** Core runtimes present, in canonical install order: rust, go, node. */ + runtimes: ToolKey[] + /** Non-runtime, non-python tools in insertion order (what the generator installs after runtimes). */ + tools: ToolKey[] +} + +const CORE_RUNTIME_ORDER: ToolKey[] = ['rust', 'go', 'node'] + +/** + * Resolve user selections into the full ordered set of tools to install. + * Ported from the original generator's dependency-resolution logic. + */ +export function resolveTools(sel: Selections): ResolvedTools { + const required = new Set() + + for (const lang of sel.coreLanguages ?? []) { + if (lang === 'rust') required.add('rust') + else if (lang === 'python') required.add('python') + else if (lang === 'go') required.add('go') + else if (lang === 'node') required.add('node') + } + + if (sel.languages?.includes('solidity')) { + required.add('python') + required.add('solc-select') + } + if (sel.languages?.includes('vyper')) { + required.add('python') + required.add('vyper') + } + + for (const framework of sel.frameworks ?? []) { + if (framework === 'foundry') { + required.add('rust') + required.add('foundry') + } else if (framework === 'hardhat') { + required.add('node') + required.add('hardhat') + } else if (framework === 'ape') { + required.add('python') + required.add('ape') + } + } + + for (const tool of sel.fuzzingAndTesting ?? []) { + if (isToolKey(tool)) { + required.add(tool) + if (tool === 'echidna' || tool === 'medusa') required.add('go') + if (tool === 'ityfuzz' || tool === 'aderyn') required.add('rust') + if (tool === 'halmos') required.add('python') + } + } + + for (const tool of sel.securityTooling ?? []) { + if (isToolKey(tool)) { + required.add(tool) + if ( + ['slither', 'mythril', 'crytic-compile', 'panoramix', 'slither-lsp', 'napalm-toolbox', 'semgrep', 'slitherin'].includes( + tool, + ) + ) { + required.add('python') + } + if (tool === 'heimdall') required.add('rust') + } + } + + for (const agent of sel.aiAgents ?? []) { + if (isToolKey(agent)) { + required.add(agent) + // claude/codex/opencode are all distributed as npm packages. + required.add('node') + } + } + + const all = [...required] + const runtimes = CORE_RUNTIME_ORDER.filter((r) => required.has(r)) + const skip = new Set([...CORE_RUNTIME_ORDER, 'python']) + // Tools other than runtimes/python, preserving insertion order. + const tools = all.filter((t) => !skip.has(t)) + + return { + all, + needsPython: required.has('python'), + runtimes, + tools, + } +} diff --git a/packages/core/src/domain/hardening.ts b/packages/core/src/domain/hardening.ts new file mode 100644 index 0000000..c11652d --- /dev/null +++ b/packages/core/src/domain/hardening.ts @@ -0,0 +1,53 @@ +/** + * Catalog of canonical hardening options. The translator maps each key to + * engine-neutral effects (see `hardening/effects.ts`); this module is the + * single source of truth for the key vocabulary + UI metadata. + */ + +export type HardeningCategory = 'filesystem' | 'container' | 'network' | 'resources' | 'application' + +export interface HardeningOption { + key: HardeningKey + label: string + description: string + category: HardeningCategory +} + +export type HardeningKey = + | 'readonly-os' + | 'ephemeral-workspace' + | 'secure-tmp' + | 'drop-caps' + | 'no-new-privs' + | 'apparmor' + | 'network-none' + | 'disable-ipv6' + | 'secure-dns' + | 'no-raw-packets' + | 'resource-limits-light' + | 'resource-limits-standard' + | 'resource-limits-heavy' + | 'vscode-security' + +export const HARDENING_OPTIONS: HardeningOption[] = [ + { key: 'readonly-os', label: 'Read-only root filesystem', description: 'Mount the rootfs read-only with writable tmpfs for caches/tmp.', category: 'filesystem' }, + { key: 'ephemeral-workspace', label: 'Ephemeral workspace', description: 'Mount /workspace as tmpfs; nothing persists.', category: 'filesystem' }, + { key: 'secure-tmp', label: 'Secure /tmp', description: 'tmpfs /tmp and /var/tmp with noexec,nosuid.', category: 'filesystem' }, + { key: 'drop-caps', label: 'Drop all capabilities', description: 'Drop all Linux capabilities (--cap-drop=ALL).', category: 'container' }, + { key: 'no-new-privs', label: 'No new privileges', description: 'Prevent privilege escalation (no-new-privileges).', category: 'container' }, + { key: 'apparmor', label: 'AppArmor profile', description: 'Apply the docker-default AppArmor profile.', category: 'container' }, + { key: 'network-none', label: 'No network', description: 'Fully air-gap the container (--network=none).', category: 'network' }, + { key: 'disable-ipv6', label: 'Disable IPv6', description: 'Disable IPv6 via sysctls.', category: 'network' }, + { key: 'secure-dns', label: 'Secure DNS', description: 'Force Cloudflare resolvers (1.1.1.1 / 1.0.0.1).', category: 'network' }, + { key: 'no-raw-packets', label: 'No raw packets', description: 'Drop NET_RAW so packet-crafting tools cannot run.', category: 'network' }, + { key: 'resource-limits-light', label: 'Resource limits: light', description: '512m memory, 2 CPUs.', category: 'resources' }, + { key: 'resource-limits-standard', label: 'Resource limits: standard', description: '2g memory, 4 CPUs.', category: 'resources' }, + { key: 'resource-limits-heavy', label: 'Resource limits: heavy', description: '4g memory, 8 CPUs.', category: 'resources' }, + { key: 'vscode-security', label: 'Editor hardening (no-op)', description: 'No effect in shell-first mode; kept for profile compatibility.', category: 'application' }, +] + +export const HARDENING_KEYS: HardeningKey[] = HARDENING_OPTIONS.map((o) => o.key) + +export function isHardeningKey(value: string): value is HardeningKey { + return HARDENING_KEYS.includes(value as HardeningKey) +} diff --git a/packages/core/src/core/scripts/install_commands.ts b/packages/core/src/domain/install-commands.ts similarity index 72% rename from packages/core/src/core/scripts/install_commands.ts rename to packages/core/src/domain/install-commands.ts index 1d3111b..4e03b42 100644 --- a/packages/core/src/core/scripts/install_commands.ts +++ b/packages/core/src/domain/install-commands.ts @@ -1,7 +1,20 @@ -export const INSTALL_COMMANDS: Record = { +/** + * Per-tool Containerfile install snippets. + * + * Ported from the original `packages/core` wizard. The snippets assume the + * runtime contract reproduced by `containerfile/base.ts`: `USER vscode`, + * `$HOME=/home/vscode`, zsh as the login + RUN shell (`SHELL ["/bin/zsh","-ic"]`), + * and a PATH pre-seeded with `~/.local/bin`, `~/.cargo/bin`, `~/.local/share/pnpm`. + * Several snippets hardcode `/home/vscode/...`, so the base image MUST keep that + * exact user/home layout. + * + * `python` is intentionally a no-op marker — Python is installed inline by the + * generator (apt deps + uv + `uv python install`), not by this snippet. + */ +export const INSTALL_COMMANDS = { // Deps python: ` -# This entry exists for dependency tracking only, python is installed via uv in generate_dev_env.ts +# This entry exists for dependency tracking only, python is installed via uv in the generator `, rust: ` # Install rust @@ -50,7 +63,7 @@ RUN uv tool install eth-ape echidna: ` # Echidna is copied from multi-stage build - verify installation RUN echo 'Echidna installed via multi-stage build' && \\ - which echidna || echo 'Echidna binary ready for use' + which echidna `, ityfuzz: ` # Install ItyFuzz @@ -62,10 +75,10 @@ RUN curl -fsSL https://ity.fuzz.land/ | zsh && \ medusa: ` # Build and install Medusa WORKDIR $HOME/medusa -RUN git clone https://github.com/crytic/medusa $HOME/medusa && \ - export LATEST_TAG="$(git describe --tags | sed 's/-[0-9]\+-g\w\+$//')" && \ - git checkout "$LATEST_TAG" && \ - go build -trimpath -o=$HOME/.local/bin/medusa -ldflags="-s -w" && \ +RUN git clone https://github.com/crytic/medusa $HOME/medusa && \\ + export LATEST_TAG="$(git describe --tags | sed 's/-[0-9]\\+-g\\w\\+$//')" && \\ + git checkout "$LATEST_TAG" && \\ + go build -trimpath -o=$HOME/.local/bin/medusa -ldflags="-s -w" && \\ chmod 755 $HOME/.local/bin/medusa WORKDIR $HOME RUN rm -rf medusa/ @@ -112,7 +125,7 @@ RUN uv tool install slitherin # Install Heimdall (uses bifrost binary, requires rust/cargo env) RUN /bin/zsh -c "curl -fsSL https://get.heimdall.rs | zsh" && \ echo 'export PATH="$HOME/.bifrost/bin:$PATH"' >> ~/.zshrc && \ - /bin/zsh -c "source ~/.cargo/env && source ~/.zshrc && bifrost --version || echo 'Heimdall installed'" + /bin/zsh -c "source ~/.cargo/env && source ~/.zshrc && bifrost --version" ENV PATH="/home/vscode/.bifrost/bin:/home/vscode/.cargo/bin:$PATH" `, @@ -133,7 +146,26 @@ RUN uv tool install solc-select && \ RUN /bin/zsh -c "curl -fsSL https://raw.githubusercontent.com/Cyfrin/up/main/install | zsh" && \ echo 'export PATH="$HOME/.cyfrin/bin:$PATH"' >> ~/.zshrc ENV PATH="/home/vscode/.cyfrin/bin:$PATH" -RUN /bin/zsh -c "source ~/.zshrc && (~/.cyfrin/bin/cyfrinup || cyfrinup)"` -}; +RUN /bin/zsh -c "source ~/.zshrc && (~/.cyfrin/bin/cyfrinup || cyfrinup)"`, -export type ToolKey = keyof typeof INSTALL_COMMANDS; + // AI coding agents (npm packages; installed globally with npm so the bin lands + // next to node on the nvm PATH — reachable from an interactive zsh at runtime). + claude: ` +# Install Anthropic Claude Code CLI +RUN npm install -g @anthropic-ai/claude-code + `, + codex: ` +# Install OpenAI Codex CLI +RUN npm install -g @openai/codex + `, + opencode: ` +# Install opencode agent CLI +RUN npm install -g opencode-ai + `, +} as const + +export type ToolKey = keyof typeof INSTALL_COMMANDS + +export function isToolKey(value: string): value is ToolKey { + return value in INSTALL_COMMANDS +} diff --git a/packages/core/src/domain/normalize.ts b/packages/core/src/domain/normalize.ts new file mode 100644 index 0000000..ec6f531 --- /dev/null +++ b/packages/core/src/domain/normalize.ts @@ -0,0 +1,53 @@ +import { HARDENING_KEYS, isHardeningKey, type HardeningKey } from './hardening.js' + +/** + * Aliases reconciling the inconsistent resource-limit keys that existed across + * the original wizard's three source files (UI used `resource-limits` / + * `-medium` / `-heavy`; the flag mapper used `-light` / `-standard` / `-heavy`). + * One canonical enum lives in `hardening.ts`; everything else maps onto it here. + */ +export const HARDENING_ALIASES: Record = { + 'resource-limits': 'resource-limits-light', + 'resource-limits-medium': 'resource-limits-standard', +} + +const RESOURCE_LIMIT_KEYS: HardeningKey[] = [ + 'resource-limits-light', + 'resource-limits-standard', + 'resource-limits-heavy', +] + +export interface NormalizeResult { + keys: HardeningKey[] + /** Inputs that did not map to any known hardening key. */ + unknown: string[] +} + +/** + * Normalize raw hardening keys: apply aliases, drop duplicates, collapse + * conflicting resource-limit selections to the strongest, and surface unknowns. + */ +export function normalizeHardening(input: string[]): NormalizeResult { + const seen = new Set() + const unknown: string[] = [] + + for (const raw of input) { + const mapped = HARDENING_ALIASES[raw] ?? raw + if (isHardeningKey(mapped)) { + seen.add(mapped) + } else { + unknown.push(raw) + } + } + + // Only one resource-limit tier may be active; keep the strongest. + const tiers = RESOURCE_LIMIT_KEYS.filter((k) => seen.has(k)) + if (tiers.length > 1) { + for (const t of tiers) seen.delete(t) + seen.add(tiers[tiers.length - 1]!) + } + + // Preserve canonical catalog order for determinism. + const keys = HARDENING_KEYS.filter((k) => seen.has(k)) + return { keys, unknown } +} diff --git a/packages/core/src/domain/profiles.ts b/packages/core/src/domain/profiles.ts new file mode 100644 index 0000000..5b02d6a --- /dev/null +++ b/packages/core/src/domain/profiles.ts @@ -0,0 +1,119 @@ +import { normalizeHardening } from './normalize.js' +import type { HardeningKey } from './hardening.js' + +/** + * Named security profiles → hardening key sets. Ported from the original + * wizard's RECIPE_MAPPINGS. Keys are normalized through `normalizeHardening` + * so any legacy aliases resolve to the canonical vocabulary. + */ +export interface ProfileDefinition { + key: ProfileKey + label: string + description: string + caveat: string + experimental: boolean + choices: string[] +} + +export type ProfileKey = + | 'development' + | 'hardened' + | 'airgapped' + | 'paranoid' + | 'network-restricted-analysis' + | 'ci-like-local-runner' + | 'package-install-session' + | 'security-research-controlled-net' + +export const PROFILES: ProfileDefinition[] = [ + { + key: 'development', + label: 'Development', + description: 'Balanced security for daily development work.', + caveat: 'Standard development environment with basic security hardening.', + experimental: false, + choices: ['secure-tmp', 'no-new-privs', 'apparmor', 'secure-dns', 'vscode-security'], + }, + { + key: 'hardened', + label: 'Hardened', + description: 'Enhanced security for smart contract auditing and security research.', + caveat: 'Packet-crafting tools will not work due to no-raw-packets restriction.', + experimental: false, + choices: ['ephemeral-workspace', 'secure-tmp', 'drop-caps', 'no-new-privs', 'apparmor', 'no-raw-packets', 'secure-dns', 'vscode-security'], + }, + { + key: 'airgapped', + label: 'Air-gapped', + description: 'Hardened with no network access.', + caveat: 'No network access — extensions and package managers will not work.', + experimental: false, + choices: ['ephemeral-workspace', 'secure-tmp', 'drop-caps', 'no-new-privs', 'apparmor', 'no-raw-packets', 'secure-dns', 'vscode-security', 'network-none'], + }, + { + key: 'paranoid', + label: 'Paranoid', + description: 'Maximum security with air-gapped, read-only environment.', + caveat: 'No network access or persistent storage — extensions and package managers will not work.', + experimental: true, + choices: ['readonly-os', 'ephemeral-workspace', 'secure-tmp', 'drop-caps', 'no-new-privs', 'apparmor', 'network-none', 'vscode-security'], + }, + { + key: 'network-restricted-analysis', + label: 'Network Restricted Analysis', + description: 'For web APIs, git, and package installs without packet crafting capabilities.', + caveat: 'Packet-crafting tools will not work.', + experimental: true, + choices: ['ephemeral-workspace', 'secure-tmp', 'drop-caps', 'no-new-privs', 'apparmor', 'no-raw-packets', 'secure-dns'], + }, + { + key: 'ci-like-local-runner', + label: 'CI-like Local Runner', + description: 'Mirrors CI behavior locally with immutable file system.', + caveat: 'Cache writes will not persist across runs.', + experimental: true, + choices: ['readonly-os', 'ephemeral-workspace', 'secure-tmp', 'drop-caps', 'no-new-privs', 'apparmor', 'secure-dns'], + }, + { + key: 'package-install-session', + label: 'Package Install Session', + description: 'Allows installing packages while keeping guardrails in place.', + caveat: 'Omit drop-caps if installs fail unexpectedly.', + experimental: true, + choices: ['ephemeral-workspace', 'secure-tmp', 'no-new-privs', 'apparmor', 'secure-dns', 'vscode-security'], + }, + { + key: 'security-research-controlled-net', + label: 'Security Research (Controlled Net)', + description: 'For API testing and collectors without packet crafting capability.', + caveat: 'Packet-crafting tools will not work.', + experimental: true, + choices: ['ephemeral-workspace', 'secure-tmp', 'drop-caps', 'no-new-privs', 'apparmor', 'no-raw-packets', 'secure-dns'], + }, +] + +const PROFILE_BY_KEY = new Map(PROFILES.map((p) => [p.key, p])) + +/** Profile applied when the user expresses no preference at all. */ +export const DEFAULT_PROFILE = 'development' + +/** Explicit opt-out value for `--profile`: run with no hardening at all. */ +export const NO_PROFILE = 'none' + +export function isProfileKey(value: string): value is ProfileKey { + return PROFILE_BY_KEY.has(value as ProfileKey) +} + +export function getProfile(key: string): ProfileDefinition | undefined { + return PROFILE_BY_KEY.get(key as ProfileKey) +} + +/** Expand one or more profile keys into a normalized, deduped hardening set. */ +export function recipesToHardening(selected: string[]): HardeningKey[] { + const choices: string[] = [] + for (const key of selected) { + const profile = PROFILE_BY_KEY.get(key as ProfileKey) + if (profile) choices.push(...profile.choices) + } + return normalizeHardening(choices).keys +} diff --git a/packages/core/src/engine/drivers/apple-container.ts b/packages/core/src/engine/drivers/apple-container.ts new file mode 100644 index 0000000..26fe610 --- /dev/null +++ b/packages/core/src/engine/drivers/apple-container.ts @@ -0,0 +1,137 @@ +import { capture } from '../exec.js' +import type { + ContainerInfo, + DetectResult, + EngineCapabilities, + EngineName, + PsFilter, +} from '../types.js' +import { fullCaps } from './capabilities.js' +import { CliDriver } from './cli-driver.js' + +/** + * Apple Containers (`container` CLI): each container runs in its own lightweight VM. + * + * Verified against `container` CLI 1.0.0: `--cap-drop` IS exposed and genuinely + * enforced (--cap-drop ALL takes CapEff from 00000000a80425fb to 0000000000000000), + * so it is declared supported rather than discarded. `--read-only` is enforced too, + * but is unusable here without the uid-mapped tmpfs mounts it is always paired with, + * so it stays unsupported (see below). Linux MAC primitives (AppArmor, seccomp, + * sysctl, no-new-privileges) and `--network=none` are not honored — those stay + * unsupported so the translator drops them with an explanatory warning rather than + * emitting flags that do nothing. + */ +export class AppleContainerDriver extends CliDriver { + readonly name: EngineName = 'apple-container' + readonly displayName = 'Apple Containers' + readonly capabilities: EngineCapabilities = fullCaps({ + // Emitting --read-only WITHOUT the writable tmpfs mounts that `readonly-os` + // pairs with it would leave the container unable to write $HOME at all, so + // these two stay coupled: tmpfs is unsupported here (below), therefore + // read-only rootfs must be too. + readOnlyRootfs: { + support: 'unsupported', + note: 'Read-only rootfs needs the paired writable tmpfs mounts, which this CLI cannot express (see tmpfs); enabling it alone would leave $HOME unwritable.', + }, + tmpfs: { + support: 'unsupported', + note: 'The container CLI takes the whole --tmpfs argument as a literal mount path, so Docker-style options are not parsed. Emitting a bare path would drop uid/gid/mode, mounting root-owned empty tmpfs over /home/vscode/.local and .ssh — hiding baked tools and breaking `dcw attach`.', + }, + capDrop: { support: 'supported' }, + noNewPrivs: { support: 'unsupported', note: 'no-new-privileges is not exposed; relies on VM isolation.' }, + apparmor: { support: 'unsupported', note: 'No AppArmor; relies on VM isolation.' }, + seccomp: { support: 'unsupported', note: 'No seccomp profile control; relies on VM isolation.' }, + networkNone: { + support: 'unsupported', + note: 'The container CLI does not honor Docker --network=none; air-gap is not enforced.', + }, + sysctl: { support: 'unsupported', note: 'sysctl tuning is not exposed.' }, + dns: { support: 'caveated', note: 'DNS configuration support is limited.' }, + memoryLimit: { support: 'caveated', note: 'Memory limits use a different syntax and granularity.' }, + cpuLimit: { support: 'caveated', note: 'CPU limits use a different syntax and granularity.' }, + userNamespaces: { support: 'unsupported', note: 'User-namespace remapping is not applicable (VM-isolated).' }, + }) + + constructor() { + super({ + bin: 'container', + verbs: { rm: 'delete', ps: 'list' }, + }) + } + + override async detect(): Promise { + const ver = await capture('container', ['--version']) + if (ver.spawnError) { + return { available: false, reason: '`container` CLI not found on PATH.' } + } + const version = ver.stdout.trim().split('\n')[0] + const status = await capture('container', ['list']) + if (status.code !== 0) { + return { available: false, version, reason: status.stderr.trim() || 'Apple Containers service is not running.' } + } + return { available: true, version } + } + + override async ps(filter: PsFilter = {}): Promise { + const args = ['list', '--format', 'json'] + if (filter.all ?? true) args.push('--all') + const res = await capture('container', args) + if (res.code !== 0) return [] + // `container list` has no --filter flag, so the label filter is applied here. + return parseAppleList(res.stdout, filter.label) + } +} + +/** + * Parse `container list --format json`. + * + * Apple's schema is NOT docker's flat `{ID,Names,Image,Status,Labels}`: each row is + * `{ id, status: { state }, configuration: { id, labels, image: { reference } } }`. + * Reading docker's field names off it yields `name: ''`, `status: '[object Object]'` + * and no labels — which made `dcw ls` report a running container as `absent`, broke + * the presence guard in `stop`/`rm` (every env matched the unrelated `buildkit` + * container), and left environments unremovable. Apple has no separate name: the + * `--name` given at run time IS the container id. + */ +export function parseAppleList(stdout: string, label?: string): ContainerInfo[] { + let rows: unknown + try { + rows = JSON.parse(stdout) + } catch { + return [] + } + if (!Array.isArray(rows)) return [] + + const out: ContainerInfo[] = [] + for (const row of rows as Array | null>) { + // A single malformed row must not take down `ls`, `stop`, `rm` and attach + // discovery — skip it the way parsePsJson skips a bad NDJSON line. + if (!row || typeof row !== 'object') continue + const cfg = (row.configuration ?? {}) as Record + const status = (row.status ?? {}) as Record + const id = String(row.id ?? cfg.id ?? '') + if (!id) continue + + const rawLabels = (cfg.labels ?? {}) as Record + const labels: Record = {} + for (const [k, v] of Object.entries(rawLabels)) labels[k] = String(v) + + const image = (cfg.image ?? {}) as Record + + out.push({ + id, + // Apple's `--name` sets the container id; there is no distinct name field. + name: id, + image: String(image.reference ?? ''), + status: String(status.state ?? ''), + labels, + }) + } + + if (!label) return out + const eq = label.indexOf('=') + if (eq < 0) return out.filter((c) => label in c.labels) + const key = label.slice(0, eq) + const value = label.slice(eq + 1) + return out.filter((c) => c.labels[key] === value) +} diff --git a/packages/core/src/engine/drivers/capabilities.ts b/packages/core/src/engine/drivers/capabilities.ts new file mode 100644 index 0000000..9017a5c --- /dev/null +++ b/packages/core/src/engine/drivers/capabilities.ts @@ -0,0 +1,75 @@ +import type { Capability, CapabilityKey, EngineCapabilities } from '../types.js' + +export const CAPABILITY_KEYS: readonly CapabilityKey[] = [ + 'readOnlyRootfs', + 'tmpfs', + 'capDrop', + 'noNewPrivs', + 'apparmor', + 'seccomp', + 'networkNone', + 'sysctl', + 'dns', + 'memoryLimit', + 'cpuLimit', + 'userNamespaces', +] as const + +/** + * Build a capability map, defaulting every key to 'supported' unless overridden. + * Use ONLY for engines that are genuinely Docker-flag-compatible (docker, orbstack, + * and near-Docker podman/lima): a new capability key would default to 'supported', + * which is the safe assumption *only* for those engines. + */ +export function caps(overrides: Partial> = {}): EngineCapabilities { + const out = {} as EngineCapabilities + for (const key of CAPABILITY_KEYS) { + out[key] = overrides[key] ?? { support: 'supported' } + } + return out +} + +/** + * Build a capability map where EVERY key must be declared explicitly. Use for + * non-Docker engines (e.g. apple-container) so adding a new CapabilityKey is a + * compile error until the driver states its stance — fail-closed, not fail-open. + */ +export function fullCaps(map: Record): EngineCapabilities { + return map +} + +/** + * Capability map for Docker-compatible engines (docker, orbstack). + * + * `--security-opt apparmor=...` is accepted by any Docker daemon, but only *applied* + * where the kernel actually has the AppArmor LSM. On macOS/Windows these engines run + * a Linux VM without it: `docker inspect` returns an empty `AppArmorProfile` and the + * container has no `/proc/self/attr/current` (verified on OrbStack 29.4.0 / macOS). + * Declaring it plainly `supported` let `--strict` pass on a control that does + * nothing — precisely what `--strict` exists to catch. Podman and Lima already model + * this with `enforced: false`. + * + * `hasApparmor` must come from the DAEMON, not the CLI host: with + * `DOCKER_HOST=ssh://linux-box` a macOS client drives an AppArmor-capable daemon (and + * the reverse is equally possible). Pass `undefined` when it has not been probed yet + * — the default is fail-closed, since claiming enforcement we cannot verify is the + * failure mode that matters here. + */ +export function dockerCaps(hasApparmor?: boolean): EngineCapabilities { + if (hasApparmor === true) return caps() + return caps({ + apparmor: { + support: 'caveated', + enforced: false, + note: + hasApparmor === false + ? 'The Docker daemon does not report AppArmor support, so the profile flag is accepted but not enforced.' + : 'AppArmor support could not be confirmed with the Docker daemon; treating the profile flag as unenforced.', + }, + }) +} + +/** True when `docker info` reports the daemon has the AppArmor LSM available. */ +export function daemonReportsApparmor(dockerInfoSecurityOptions: string): boolean { + return /name=apparmor/i.test(dockerInfoSecurityOptions) +} diff --git a/packages/core/src/engine/drivers/cli-driver.ts b/packages/core/src/engine/drivers/cli-driver.ts new file mode 100644 index 0000000..7e3b94b --- /dev/null +++ b/packages/core/src/engine/drivers/cli-driver.ts @@ -0,0 +1,231 @@ +import { DcwError } from '../../errors.js' +import { capture, inherit, type CaptureResult } from '../exec.js' +import type { + BuildSpec, + ContainerInfo, + DetectResult, + EngineCapabilities, + EngineDriver, + EngineName, + ExecSpec, + LogsOptions, + PsFilter, + RunSpec, +} from '../types.js' + +/** Subcommand verbs that differ between docker-like CLIs (Apple `container` renames some). */ +export interface CliVerbs { + build: string + run: string + exec: string + stop: string + rm: string + ps: string + logs: string +} + +const DEFAULT_VERBS: CliVerbs = { + build: 'build', + run: 'run', + exec: 'exec', + stop: 'stop', + rm: 'rm', + ps: 'ps', + logs: 'logs', +} + +export interface CliConfig { + bin: string + /** Subcommand prefix inserted before every verb (e.g. ['nerdctl'] for `lima nerdctl ...`). */ + prefix?: string[] + /** Binary used for the version probe (defaults to `bin`). */ + versionBin?: string + versionArgs?: string[] + verbs?: Partial +} + +/** Shared implementation for docker-compatible CLIs (docker, podman, orbstack, nerdctl/lima). */ +export abstract class CliDriver implements EngineDriver { + abstract readonly name: EngineName + abstract readonly displayName: string + abstract readonly capabilities: EngineCapabilities + + protected readonly cfg: CliConfig + protected readonly verbs: CliVerbs + + constructor(cfg: CliConfig) { + this.cfg = cfg + this.verbs = { ...DEFAULT_VERBS, ...cfg.verbs } + } + + /** Compose full argv: prefix + rest. */ + protected argv(...rest: string[]): string[] { + return [...(this.cfg.prefix ?? []), ...rest] + } + + protected get bin(): string { + return this.cfg.bin + } + + protected fail(action: string, stderr: string): never { + const detail = stderr.trim() + throw new DcwError(`${this.displayName} ${action} failed${detail ? `: ${detail}` : '.'}`) + } + + async detect(): Promise { + const versionBin = this.cfg.versionBin ?? this.cfg.bin + const versionArgs = this.cfg.versionArgs ?? ['--version'] + const ver = await capture(versionBin, versionArgs) + if (ver.spawnError) { + return { available: false, reason: `${versionBin} not found on PATH.` } + } + const version = ver.stdout.trim().split('\n')[0] + // Probe daemon/runtime readiness. + const info = await capture(this.bin, this.argv('info')) + if (info.code !== 0) { + return { available: false, version, reason: info.stderr.trim() || `${this.displayName} runtime is not ready.` } + } + return { available: true, version } + } + + async build(spec: BuildSpec): Promise<{ imageId: string }> { + const args = this.argv(this.verbs.build, '-f', spec.containerfilePath, '-t', spec.tag) + if (spec.platform) args.push('--platform', spec.platform) + if (spec.noCache) args.push('--no-cache') + for (const [k, v] of Object.entries(spec.buildArgs ?? {})) { + args.push('--build-arg', `${k}=${v}`) + } + args.push(spec.contextDir) + + const code = await inherit(this.bin, args) + if (code !== 0) throw new DcwError(`${this.displayName} build failed (exit ${code}).`) + + const inspect = await capture(this.bin, this.argv('image', 'inspect', spec.tag, '--format', '{{.Id}}')) + return { imageId: inspect.code === 0 ? inspect.stdout.trim() : spec.tag } + } + + async run(spec: RunSpec): Promise<{ containerId: string }> { + const args = this.argv(this.verbs.run) + if (spec.detach) args.push('-d') + args.push('--name', spec.name) + for (const [k, v] of Object.entries(spec.labels ?? {})) { + args.push('--label', `${k}=${v}`) + } + args.push(...spec.flags) + if (spec.workdir) args.push('-w', spec.workdir) + for (const [k, v] of Object.entries(spec.env ?? {})) { + args.push('-e', `${k}=${v}`) + } + args.push(spec.image) + if (spec.command?.length) args.push(...spec.command) + + const res = await capture(this.bin, args) + if (res.code !== 0) this.fail('run', res.stderr) + return { containerId: res.stdout.trim().split('\n').pop() ?? '' } + } + + protected execArgv(spec: ExecSpec): string[] { + const args = this.argv(this.verbs.exec) + if (spec.interactive) args.push('-i') + if (spec.tty) args.push('-t') + if (spec.user) args.push('--user', spec.user) + // Pass `-e NAME` value-less: the engine inherits the value from our process + // environment (see execEnv) so secrets never appear in argv / the host `ps`. + for (const k of Object.keys(spec.env ?? {})) { + args.push('-e', k) + } + args.push(spec.container, ...spec.cmd) + return args + } + + /** Merge the exec env vars into our process env so `-e NAME` can inherit them. */ + private execEnv(spec: ExecSpec): NodeJS.ProcessEnv | undefined { + if (!spec.env || Object.keys(spec.env).length === 0) return undefined + return { ...process.env, ...spec.env } + } + + async exec(spec: ExecSpec): Promise { + return inherit(this.bin, this.execArgv(spec), { env: this.execEnv(spec) }) + } + + async execCapture(spec: ExecSpec, input?: string): Promise { + const env = this.execEnv(spec) + return capture(this.bin, this.execArgv(spec), { + ...(input !== undefined ? { input } : {}), + ...(env ? { env } : {}), + }) + } + + async stop(id: string): Promise { + const res = await capture(this.bin, this.argv(this.verbs.stop, id)) + if (res.code !== 0) this.fail('stop', res.stderr) + } + + async rm(id: string, opts: { force?: boolean } = {}): Promise { + const args = this.argv(this.verbs.rm) + if (opts.force) args.push('-f') + args.push(id) + const res = await capture(this.bin, args) + if (res.code !== 0) this.fail('rm', res.stderr) + } + + async ps(filter: PsFilter = {}): Promise { + const args = this.argv(this.verbs.ps) + if (filter.all ?? true) args.push('-a') + if (filter.label) args.push('--filter', `label=${filter.label}`) + args.push('--format', '{{json .}}') + const res = await capture(this.bin, args) + if (res.code !== 0) return [] + return parsePsJson(res.stdout) + } + + async runOnce(image: string, cmd: string[], flags: string[] = []): Promise<{ stdout: string; code: number }> { + const res = await capture(this.bin, this.argv(this.verbs.run, '--rm', ...flags, image, ...cmd)) + return { stdout: res.stdout, code: res.code } + } + + async logs(id: string, opts: LogsOptions = {}): Promise { + const args = this.argv(this.verbs.logs) + if (opts.follow) args.push('-f') + if (opts.tail !== undefined) args.push('--tail', String(opts.tail)) + args.push(id) + return inherit(this.bin, args) + } +} + +/** Parse `--format '{{json .}}'` NDJSON output from docker/podman/nerdctl `ps`. */ +export function parsePsJson(stdout: string): ContainerInfo[] { + const out: ContainerInfo[] = [] + for (const line of stdout.split('\n')) { + const trimmed = line.trim() + if (!trimmed) continue + try { + const row = JSON.parse(trimmed) as Record + out.push({ + id: String(row.ID ?? row.Id ?? ''), + name: String(row.Names ?? row.Name ?? ''), + image: String(row.Image ?? ''), + status: String(row.Status ?? row.State ?? ''), + labels: parseLabels(row.Labels), + }) + } catch { + // skip malformed lines + } + } + return out +} + +function parseLabels(value: unknown): Record { + const labels: Record = {} + if (typeof value === 'string') { + for (const pair of value.split(',')) { + const idx = pair.indexOf('=') + if (idx > 0) labels[pair.slice(0, idx)] = pair.slice(idx + 1) + } + } else if (value && typeof value === 'object') { + for (const [k, v] of Object.entries(value as Record)) { + labels[k] = String(v) + } + } + return labels +} diff --git a/packages/core/src/engine/drivers/docker.ts b/packages/core/src/engine/drivers/docker.ts new file mode 100644 index 0000000..5b18fb1 --- /dev/null +++ b/packages/core/src/engine/drivers/docker.ts @@ -0,0 +1,28 @@ +import type { DetectResult, EngineCapabilities, EngineName } from '../types.js' +import { capture } from '../exec.js' +import { daemonReportsApparmor, dockerCaps } from './capabilities.js' +import { CliDriver } from './cli-driver.js' + +/** Docker: full Linux MAC + capability + resource control. */ +export class DockerDriver extends CliDriver { + readonly name: EngineName = 'docker' + readonly displayName = 'Docker' + capabilities: EngineCapabilities = dockerCaps() + + constructor() { + super({ bin: 'docker' }) + } + + override async detect(): Promise { + const base = await super.detect() + if (base.available) await this.refreshApparmorCapability() + return base + } + + /** Ask the DAEMON (not this host) whether AppArmor is actually available. */ + protected async refreshApparmorCapability(): Promise { + const info = await capture(this.bin, this.argv('info', '--format', '{{json .SecurityOptions}}')) + if (info.code !== 0) return // leave the fail-closed default in place + this.capabilities = dockerCaps(daemonReportsApparmor(info.stdout)) + } +} diff --git a/packages/core/src/engine/drivers/lima.ts b/packages/core/src/engine/drivers/lima.ts new file mode 100644 index 0000000..9b2f1b8 --- /dev/null +++ b/packages/core/src/engine/drivers/lima.ts @@ -0,0 +1,32 @@ +import type { EngineCapabilities, EngineName } from '../types.js' +import { caps } from './capabilities.js' +import { CliDriver } from './cli-driver.js' + +/** + * Lima runs containers via nerdctl inside a Linux guest VM (`lima nerdctl ...`). + * Mirrors Docker, but AppArmor/sysctl depend on what the guest enables. + */ +export class LimaDriver extends CliDriver { + readonly name: EngineName = 'lima' + readonly displayName = 'Lima (nerdctl)' + readonly capabilities: EngineCapabilities = caps({ + apparmor: { + support: 'caveated', + enforced: false, + note: 'AppArmor depends on the Lima guest VM profile.', + }, + sysctl: { + support: 'caveated', + note: 'sysctl support depends on the Lima guest VM.', + }, + }) + + constructor() { + super({ + bin: 'lima', + prefix: ['nerdctl'], + versionBin: 'limactl', + versionArgs: ['--version'], + }) + } +} diff --git a/packages/core/src/engine/drivers/orbstack.ts b/packages/core/src/engine/drivers/orbstack.ts new file mode 100644 index 0000000..601daff --- /dev/null +++ b/packages/core/src/engine/drivers/orbstack.ts @@ -0,0 +1,47 @@ +import { capture } from '../exec.js' +import type { DetectResult, EngineCapabilities, EngineName } from '../types.js' +import { daemonReportsApparmor, dockerCaps } from './capabilities.js' +import { CliDriver } from './cli-driver.js' + +/** + * OrbStack speaks the Docker CLI, so capabilities match Docker. It is only + * considered "available" when the active Docker context is actually OrbStack + * (otherwise the plain Docker driver applies). + */ +export class OrbstackDriver extends CliDriver { + readonly name: EngineName = 'orbstack' + readonly displayName = 'OrbStack' + capabilities: EngineCapabilities = dockerCaps() + + constructor() { + super({ bin: 'docker' }) + } + + /** Ask the DAEMON (not this host) whether AppArmor is actually available. */ + private async refreshApparmorCapability(): Promise { + const info = await capture(this.bin, this.argv('info', '--format', '{{json .SecurityOptions}}')) + if (info.code !== 0) return // leave the fail-closed default in place + this.capabilities = dockerCaps(daemonReportsApparmor(info.stdout)) + } + + override async detect(): Promise { + const base = await super.detect() + if (!base.available) return base + await this.refreshApparmorCapability() + + // Confirm the active Docker endpoint is OrbStack. + const ctx = await capture('docker', ['context', 'show']) + if (ctx.code === 0 && ctx.stdout.trim().toLowerCase().includes('orbstack')) { + return base + } + const info = await capture('docker', ['info', '--format', '{{json .}}']) + if (info.code === 0 && /orbstack/i.test(info.stdout)) { + return base + } + return { + available: false, + version: base.version, + reason: 'Docker is running but the active context is not OrbStack.', + } + } +} diff --git a/packages/core/src/engine/drivers/podman.ts b/packages/core/src/engine/drivers/podman.ts new file mode 100644 index 0000000..4e44c9c --- /dev/null +++ b/packages/core/src/engine/drivers/podman.ts @@ -0,0 +1,24 @@ +import type { EngineCapabilities, EngineName } from '../types.js' +import { caps } from './capabilities.js' +import { CliDriver } from './cli-driver.js' + +/** Podman: near-Docker, but rootless userns affects uid-mapped tmpfs and AppArmor. */ +export class PodmanDriver extends CliDriver { + readonly name: EngineName = 'podman' + readonly displayName = 'Podman' + readonly capabilities: EngineCapabilities = caps({ + apparmor: { + support: 'caveated', + enforced: false, + note: 'AppArmor enforcement depends on the host; may be a no-op in rootless mode.', + }, + userNamespaces: { + support: 'caveated', + note: 'Rootless: uid/gid-mapped tmpfs mounts require --userns=keep-id (added automatically).', + }, + }) + + constructor() { + super({ bin: 'podman' }) + } +} diff --git a/packages/core/src/engine/exec.ts b/packages/core/src/engine/exec.ts new file mode 100644 index 0000000..69458e5 --- /dev/null +++ b/packages/core/src/engine/exec.ts @@ -0,0 +1,62 @@ +import { spawn } from 'node:child_process' + +export interface CaptureResult { + /** Process exit code, or 127 if the binary could not be spawned (ENOENT). */ + code: number + stdout: string + stderr: string + /** True when the binary itself could not be found / spawned. */ + spawnError: boolean +} + +export interface CaptureOptions { + input?: string + env?: NodeJS.ProcessEnv + cwd?: string +} + +/** Run a command, capturing stdout/stderr. Never throws on non-zero exit. */ +export function capture(bin: string, args: string[], opts: CaptureOptions = {}): Promise { + return new Promise((resolve) => { + const child = spawn(bin, args, { + env: opts.env ?? process.env, + cwd: opts.cwd, + stdio: ['pipe', 'pipe', 'pipe'], + }) + + let stdout = '' + let stderr = '' + + child.stdout.on('data', (d) => { + stdout += d.toString() + }) + child.stderr.on('data', (d) => { + stderr += d.toString() + }) + + child.on('error', (err: NodeJS.ErrnoException) => { + resolve({ code: 127, stdout, stderr: stderr || err.message, spawnError: true }) + }) + + child.on('close', (code) => { + resolve({ code: code ?? 0, stdout, stderr, spawnError: false }) + }) + + if (opts.input !== undefined) { + child.stdin.end(opts.input) + } + }) +} + +/** Run a command with inherited stdio (interactive). Resolves with the exit code. */ +export function inherit(bin: string, args: string[], opts: { env?: NodeJS.ProcessEnv; cwd?: string } = {}): Promise { + return new Promise((resolve) => { + const child = spawn(bin, args, { + env: opts.env ?? process.env, + cwd: opts.cwd, + stdio: 'inherit', + }) + child.on('error', () => resolve(127)) + child.on('close', (code) => resolve(code ?? 0)) + }) +} diff --git a/packages/core/src/engine/host.ts b/packages/core/src/engine/host.ts new file mode 100644 index 0000000..b681c69 --- /dev/null +++ b/packages/core/src/engine/host.ts @@ -0,0 +1,105 @@ +import { capture } from './exec.js' +import type { EngineName } from './types.js' + +export type HostOS = 'macos' | 'linux' | 'windows' | 'other' +export type HostArch = 'arm64' | 'x64' | 'other' + +export interface HostInfo { + os: HostOS + arch: HostArch + /** Major macOS product version (e.g. 15), when on macOS. */ + macosMajor?: number +} + +function mapOS(platform: NodeJS.Platform): HostOS { + if (platform === 'darwin') return 'macos' + if (platform === 'linux') return 'linux' + if (platform === 'win32') return 'windows' + return 'other' +} + +function mapArch(arch: string): HostArch { + if (arch === 'arm64') return 'arm64' + if (arch === 'x64') return 'x64' + return 'other' +} + +/** Detect host OS/arch and (on macOS) the major product version. */ +export async function detectHost(): Promise { + const hostOS = mapOS(process.platform) + const arch = mapArch(process.arch) + const info: HostInfo = { os: hostOS, arch } + + if (hostOS === 'macos') { + const res = await capture('sw_vers', ['-productVersion']) + if (!res.spawnError && res.code === 0) { + const major = Number.parseInt(res.stdout.trim().split('.')[0] ?? '', 10) + if (Number.isFinite(major)) info.macosMajor = major + } + } + + return info +} + +export interface PlatformSupport { + supported: boolean + /** Reason the engine cannot run on this host (only set when unsupported). */ + reason?: string +} + +/** + * Whether an engine can run on a given host at all (independent of whether it + * is installed). Apple Containers is the strictest: macOS 15+ on Apple Silicon. + */ +export function enginePlatformSupport(engine: EngineName, host: HostInfo): PlatformSupport { + switch (engine) { + case 'docker': + case 'podman': + return { supported: true } + case 'orbstack': + return host.os === 'macos' + ? { supported: true } + : { supported: false, reason: 'OrbStack runs on macOS only.' } + case 'lima': + return host.os === 'macos' || host.os === 'linux' + ? { supported: true } + : { supported: false, reason: 'Lima runs on macOS and Linux only.' } + case 'apple-container': { + if (host.os !== 'macos') { + return { supported: false, reason: 'Apple Containers requires macOS.' } + } + if (host.arch !== 'arm64') { + return { supported: false, reason: 'Apple Containers requires Apple Silicon (arm64).' } + } + if (host.macosMajor !== undefined && host.macosMajor < 15) { + return { supported: false, reason: 'Apple Containers requires macOS 15 (Sequoia) or newer.' } + } + return { supported: true } + } + default: + return { supported: false, reason: 'Unknown engine.' } + } +} + +/** Auto-select preference order, host-dependent (best first). */ +export function enginePreference(host: HostInfo): EngineName[] { + if (host.os === 'macos') { + return ['orbstack', 'docker', 'podman', 'apple-container', 'lima'] + } + if (host.os === 'linux') { + return ['docker', 'podman', 'lima'] + } + return ['docker', 'podman'] +} + +export function describeHost(host: HostInfo): string { + const osLabel = + host.os === 'macos' + ? `macOS${host.macosMajor ? ` ${host.macosMajor}` : ''}` + : host.os === 'linux' + ? 'Linux' + : host.os === 'windows' + ? 'Windows' + : 'unknown OS' + return `${osLabel} (${host.arch})` +} diff --git a/packages/core/src/engine/registry.ts b/packages/core/src/engine/registry.ts new file mode 100644 index 0000000..76da9bd --- /dev/null +++ b/packages/core/src/engine/registry.ts @@ -0,0 +1,32 @@ +import type { EngineDriver, EngineName } from './types.js' +import { ALL_ENGINES } from './types.js' +import { DockerDriver } from './drivers/docker.js' +import { PodmanDriver } from './drivers/podman.js' +import { OrbstackDriver } from './drivers/orbstack.js' +import { LimaDriver } from './drivers/lima.js' +import { AppleContainerDriver } from './drivers/apple-container.js' + +export function createDriver(name: EngineName): EngineDriver { + switch (name) { + case 'docker': + return new DockerDriver() + case 'podman': + return new PodmanDriver() + case 'orbstack': + return new OrbstackDriver() + case 'lima': + return new LimaDriver() + case 'apple-container': + return new AppleContainerDriver() + default: { + const exhaustive: never = name + throw new Error(`Unknown engine: ${String(exhaustive)}`) + } + } +} + +export function createAllDrivers(): EngineDriver[] { + return ALL_ENGINES.map((name) => createDriver(name)) +} + +export { ALL_ENGINES } diff --git a/packages/core/src/engine/resolver.ts b/packages/core/src/engine/resolver.ts new file mode 100644 index 0000000..248271f --- /dev/null +++ b/packages/core/src/engine/resolver.ts @@ -0,0 +1,125 @@ +import { EngineUnavailableError, EngineUnsupportedError, NoEngineError } from '../errors.js' +import { enginePlatformSupport, enginePreference, type HostInfo, type PlatformSupport } from './host.js' +import { createAllDrivers } from './registry.js' +import type { DetectResult, EngineCapabilities, EngineDriver, EngineName } from './types.js' + +/** Combined platform + availability + capability view of one engine (for the compat table). */ +export interface EngineStatus { + name: EngineName + displayName: string + platform: PlatformSupport + /** Detection result; only populated when platform-supported. */ + detect?: DetectResult + capabilities: EngineCapabilities + /** True for the engine auto-select would pick. */ + recommended: boolean +} + +export interface SurveyOptions { + host: HostInfo + /** Inject drivers for testing; defaults to all real drivers. */ + drivers?: EngineDriver[] + /** Skip detection probes (platform + capabilities only). */ + skipDetect?: boolean +} + +/** Produce a full per-engine status table, ordered by host preference. */ +export async function surveyEngines(opts: SurveyOptions): Promise { + const drivers = opts.drivers ?? createAllDrivers() + const byName = new Map(drivers.map((d) => [d.name, d])) + const order = enginePreference(opts.host) + const ordered = [ + ...order.map((n) => byName.get(n)).filter((d): d is EngineDriver => Boolean(d)), + ...drivers.filter((d) => !order.includes(d.name)), + ] + + // The recommended engine is the first platform-supported + available one in preference order. + let recommendedName: EngineName | undefined + + const statuses: EngineStatus[] = [] + for (const driver of ordered) { + const platform = enginePlatformSupport(driver.name, opts.host) + let detect: DetectResult | undefined + if (platform.supported && !opts.skipDetect) { + detect = await driver.detect() + if (detect.available && recommendedName === undefined && order.includes(driver.name)) { + recommendedName = driver.name + } + } + statuses.push({ + name: driver.name, + displayName: driver.displayName, + platform, + detect, + capabilities: driver.capabilities, + recommended: false, + }) + } + + for (const s of statuses) { + s.recommended = s.name === recommendedName + } + return statuses +} + +export interface ResolveOptions { + /** Value of --engine / DCW_ENGINE; 'auto' or undefined means auto-detect. */ + requested?: string + host: HostInfo + drivers?: EngineDriver[] +} + +export interface ResolveResult { + driver: EngineDriver + detect: DetectResult + platform: PlatformSupport +} + +/** + * Resolve the engine to use. Honors an explicit request (validated against host + * support + availability) or auto-selects the first available engine in the + * host's preference order. + */ +export async function resolveEngine(opts: ResolveOptions): Promise { + const drivers = opts.drivers ?? createAllDrivers() + const byName = new Map(drivers.map((d) => [d.name, d])) + const requested = opts.requested && opts.requested !== 'auto' ? (opts.requested as EngineName) : undefined + + if (requested) { + const driver = byName.get(requested) + if (!driver) { + throw new EngineUnsupportedError(`Unknown engine '${requested}'.`) + } + const platform = enginePlatformSupport(requested, opts.host) + if (!platform.supported) { + throw new EngineUnsupportedError(`Engine '${requested}' is not supported on this host: ${platform.reason}`) + } + const detect = await driver.detect() + if (!detect.available) { + throw new EngineUnavailableError( + `Engine '${requested}' is supported but not available: ${detect.reason ?? 'not detected.'}`, + ) + } + return { driver, detect, platform } + } + + // Auto-detect: walk preference order, return the first available engine. + const order = enginePreference(opts.host) + const unavailable: string[] = [] + for (const name of order) { + const driver = byName.get(name) + if (!driver) continue + const platform = enginePlatformSupport(name, opts.host) + if (!platform.supported) continue + const detect = await driver.detect() + if (detect.available) { + return { driver, detect, platform } + } + unavailable.push(`${driver.displayName} (${detect.reason ?? 'not detected'})`) + } + + throw new NoEngineError( + `No supported container engine is available. Tried: ${unavailable.join(', ') || 'none'}. ` + + 'Install or start Docker, Podman, OrbStack, Lima, or Apple Containers.', + ) +} diff --git a/packages/core/src/engine/types.ts b/packages/core/src/engine/types.ts new file mode 100644 index 0000000..289d789 --- /dev/null +++ b/packages/core/src/engine/types.ts @@ -0,0 +1,128 @@ +import type { CaptureResult } from './exec.js' + +export type EngineName = 'docker' | 'podman' | 'orbstack' | 'apple-container' | 'lima' + +export const ALL_ENGINES: readonly EngineName[] = [ + 'docker', + 'podman', + 'orbstack', + 'apple-container', + 'lima', +] as const + +/** + * Capability keys correspond 1:1 with the kinds of hardening the translator can + * request. The translator consults these to decide whether to emit a flag, + * emit-with-warning, or drop it. + */ +export type CapabilityKey = + | 'readOnlyRootfs' + | 'tmpfs' + | 'capDrop' + | 'noNewPrivs' + | 'apparmor' + | 'seccomp' + | 'networkNone' + | 'sysctl' + | 'dns' + | 'memoryLimit' + | 'cpuLimit' + | 'userNamespaces' + +export type CapSupport = 'supported' | 'caveated' | 'unsupported' + +export interface Capability { + support: CapSupport + /** Human-readable advisory shown when caveated, or reason when unsupported. */ + note?: string + /** + * For a `caveated` security control: `false` means the flag is emitted but the + * control may be silently inert (e.g. AppArmor under rootless Podman). `--strict` + * treats these like a dropped control. Defaults to `true` (caveat is advisory only). + */ + enforced?: boolean +} + +export type EngineCapabilities = Record + +export interface DetectResult { + available: boolean + version?: string + /** Why the engine is unavailable (binary missing, daemon down, etc.). */ + reason?: string +} + +export interface BuildSpec { + /** Absolute path to the generated Containerfile/Dockerfile. */ + containerfilePath: string + /** Build context directory. */ + contextDir: string + tag: string + platform?: string + buildArgs?: Record + noCache?: boolean +} + +export interface RunSpec { + image: string + /** Container name (e.g. dcw-). */ + name: string + /** Labels applied to the container for store<->engine reconciliation. */ + labels?: Record + /** Hardening + misc flags produced by the translator, already engine-correct. */ + flags: string[] + workdir?: string + env?: Record + /** Run detached (true for `up`). */ + detach: boolean + /** Optional entry command; defaults to the image default. */ + command?: string[] +} + +export interface ExecSpec { + container: string + cmd: string[] + interactive: boolean + tty: boolean + /** Run the command as this user inside the container (engine `-u`). */ + user?: string + /** Extra environment variables to set for the command (engine `-e`). */ + env?: Record +} + +export interface ContainerInfo { + id: string + name: string + image: string + status: string + labels: Record +} + +export interface PsFilter { + label?: string + all?: boolean +} + +export interface LogsOptions { + follow?: boolean + tail?: number +} + +export interface EngineDriver { + readonly name: EngineName + readonly displayName: string + readonly capabilities: EngineCapabilities + detect(): Promise + build(spec: BuildSpec): Promise<{ imageId: string }> + run(spec: RunSpec): Promise<{ containerId: string }> + exec(spec: ExecSpec): Promise + /** Like `exec`, but captures stdout/stderr and can feed stdin (never throws on non-zero). */ + execCapture(spec: ExecSpec, input?: string): Promise + stop(id: string): Promise + rm(id: string, opts?: { force?: boolean }): Promise + ps(filter?: PsFilter): Promise + logs(id: string, opts?: LogsOptions): Promise + /** Run a throwaway container, capturing stdout (used to read in-image reports). + * `flags` are extra `run` flags (e.g. hardening) inserted before the image. */ + runOnce(image: string, cmd: string[], flags?: string[]): Promise<{ stdout: string; code: number }> +} diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts new file mode 100644 index 0000000..7a9030c --- /dev/null +++ b/packages/core/src/errors.ts @@ -0,0 +1,75 @@ +/** Deterministic process exit codes (documented contract for scripts/agents). */ +export enum ExitCode { + Success = 0, + GenericError = 1, + UsageError = 2, + NoEngine = 3, + EngineUnsupported = 4, + EngineUnavailable = 5, + Cancelled = 6, + StrictHardening = 7, + NotFound = 8, + ValidationError = 9, +} + +/** Base error carrying a deterministic exit code and a stable machine `code`. */ +export class DcwError extends Error { + readonly exitCode: number + readonly code: string + + constructor(message: string, exitCode: number = ExitCode.GenericError, code = 'E_GENERIC') { + super(message) + this.name = 'DcwError' + this.exitCode = exitCode + this.code = code + } +} + +export class NoEngineError extends DcwError { + constructor(message: string) { + super(message, ExitCode.NoEngine, 'E_NO_ENGINE') + this.name = 'NoEngineError' + } +} + +export class EngineUnsupportedError extends DcwError { + constructor(message: string) { + super(message, ExitCode.EngineUnsupported, 'E_ENGINE_UNSUPPORTED') + this.name = 'EngineUnsupportedError' + } +} + +export class EngineUnavailableError extends DcwError { + constructor(message: string) { + super(message, ExitCode.EngineUnavailable, 'E_ENGINE_UNAVAILABLE') + this.name = 'EngineUnavailableError' + } +} + +export class StrictHardeningError extends DcwError { + constructor(message: string) { + super(message, ExitCode.StrictHardening, 'E_STRICT_HARDENING') + this.name = 'StrictHardeningError' + } +} + +export class NotFoundError extends DcwError { + constructor(message: string) { + super(message, ExitCode.NotFound, 'E_NOT_FOUND') + this.name = 'NotFoundError' + } +} + +export class ValidationError extends DcwError { + constructor(message: string) { + super(message, ExitCode.ValidationError, 'E_VALIDATION') + this.name = 'ValidationError' + } +} + +export class CancelledError extends DcwError { + constructor(message = 'Cancelled.') { + super(message, ExitCode.Cancelled, 'E_CANCELLED') + this.name = 'CancelledError' + } +} diff --git a/packages/core/src/hardening/effects.ts b/packages/core/src/hardening/effects.ts new file mode 100644 index 0000000..8b1b650 --- /dev/null +++ b/packages/core/src/hardening/effects.ts @@ -0,0 +1,167 @@ +import type { HardeningKey } from '../domain/hardening.js' +import type { CapabilityKey } from '../engine/types.js' + +/** + * Engine-neutral hardening intents. Hardening keys expand into these; the + * translator then renders engine-correct flags (or drops them) based on each + * engine's capability map. + */ +export type HardeningEffect = + | { kind: 'readonly-rootfs' } + | { kind: 'tmpfs'; target: string; opts: string } + | { kind: 'ephemeral-workspace' } + | { kind: 'drop-cap'; cap: 'ALL' | 'NET_RAW' } + | { kind: 'no-new-privs' } + | { kind: 'apparmor'; profile: string } + | { kind: 'network-none' } + | { kind: 'sysctl'; key: string; value: string } + | { kind: 'dns'; servers: string[] } + | { kind: 'resources'; memory: string; cpus: string } + | { kind: 'noop'; source: HardeningKey; reason: string } + +/** The capability each effect depends on (null = always applicable, e.g. noop). */ +export function capabilityFor(effect: HardeningEffect): CapabilityKey | null { + switch (effect.kind) { + case 'readonly-rootfs': + return 'readOnlyRootfs' + case 'tmpfs': + case 'ephemeral-workspace': + return 'tmpfs' + case 'drop-cap': + return 'capDrop' + case 'no-new-privs': + return 'noNewPrivs' + case 'apparmor': + return 'apparmor' + case 'network-none': + return 'networkNone' + case 'sysctl': + return 'sysctl' + case 'dns': + return 'dns' + case 'resources': + return 'memoryLimit' + case 'noop': + return null + } +} + +// Writable tmpfs mounts paired with a read-only rootfs (the VS Code server mounts +// from the original devcontainer config are intentionally dropped — shell-first). +const READONLY_TMPFS: Array<{ target: string; opts: string }> = [ + { target: '/tmp', opts: 'rw,noexec,nosuid,size=1g' }, + { target: '/var/tmp', opts: 'rw,noexec,nosuid,size=1g' }, + { target: '/var/log', opts: 'rw,noexec,nosuid,size=256m' }, + { target: '/run', opts: 'rw,noexec,nosuid,size=256m' }, + { target: '/home/vscode/.cache', opts: 'rw,noexec,nosuid,size=1g,uid=1000,gid=1000' }, + { target: '/home/vscode/.config', opts: 'rw,noexec,nosuid,size=512m,uid=1000,gid=1000' }, + { target: '/home/vscode/.local', opts: 'rw,noexec,nosuid,size=1g,uid=1000,gid=1000' }, + { target: '/home/vscode/.gnupg', opts: 'rw,noexec,nosuid,size=64m,uid=1000,gid=1000' }, + // Writable ~/.ssh so `dcw attach` can drop a runtime host key + authorized_keys + // (the baked sshd config lives at /etc/ssh/sshd_config.dcw, not under here). + { target: '/home/vscode/.ssh', opts: 'rw,nosuid,size=16m,uid=1000,gid=1000,mode=0700' }, +] + +/** Parse a tmpfs `size=` option into bytes for comparison (Infinity if absent). */ +function tmpfsSizeBytes(opts: string): number { + const m = /size=(\d+)([kmg]?)/i.exec(opts) + if (!m) return Number.POSITIVE_INFINITY + const unit = (m[2] ?? '').toLowerCase() + const mult = unit === 'g' ? 1024 ** 3 : unit === 'm' ? 1024 ** 2 : unit === 'k' ? 1024 : 1 + return Number(m[1]) * mult +} + +/** + * Collapse duplicate tmpfs effects targeting the same mount point (e.g. both + * `readonly-os` and `secure-tmp` mount /tmp). Keeping two `--tmpfs /tmp` specs + * is non-deterministic — which size wins is engine-dependent — so we keep the + * most restrictive (smallest) one at the position of its first occurrence. + */ +function dedupeTmpfs(effects: HardeningEffect[]): HardeningEffect[] { + const slotForTarget = new Map() + const out: HardeningEffect[] = [] + for (const effect of effects) { + if (effect.kind !== 'tmpfs') { + out.push(effect) + continue + } + const slot = slotForTarget.get(effect.target) + if (slot === undefined) { + slotForTarget.set(effect.target, out.length) + out.push(effect) + } else { + const prev = out[slot] as Extract + if (tmpfsSizeBytes(effect.opts) < tmpfsSizeBytes(prev.opts)) out[slot] = effect + } + } + return out +} + +const RESOURCE_TIERS: Record = { + 'resource-limits-light': { memory: '512m', cpus: '2' }, + 'resource-limits-standard': { memory: '2g', cpus: '4' }, + 'resource-limits-heavy': { memory: '4g', cpus: '8' }, +} + +/** + * Expand normalized hardening keys into engine-neutral effects, honoring the + * mutual-exclusions the original generator encoded (drop-caps ⊇ no-raw-packets; + * network-none supersedes disable-ipv6). + */ +export function hardeningToEffects(keys: HardeningKey[]): HardeningEffect[] { + const set = new Set(keys) + const effects: HardeningEffect[] = [] + + if (set.has('readonly-os')) { + effects.push({ kind: 'readonly-rootfs' }) + for (const m of READONLY_TMPFS) effects.push({ kind: 'tmpfs', target: m.target, opts: m.opts }) + } + + if (set.has('ephemeral-workspace')) { + effects.push({ kind: 'ephemeral-workspace' }) + } + + if (set.has('secure-tmp')) { + effects.push({ kind: 'tmpfs', target: '/tmp', opts: 'rw,noexec,nosuid,size=512m' }) + effects.push({ kind: 'tmpfs', target: '/var/tmp', opts: 'rw,noexec,nosuid,size=512m' }) + } + + // Capability dropping: ALL subsumes NET_RAW, so only emit one. + if (set.has('drop-caps')) { + effects.push({ kind: 'drop-cap', cap: 'ALL' }) + } else if (set.has('no-raw-packets')) { + effects.push({ kind: 'drop-cap', cap: 'NET_RAW' }) + } + + if (set.has('no-new-privs')) effects.push({ kind: 'no-new-privs' }) + if (set.has('apparmor')) effects.push({ kind: 'apparmor', profile: 'docker-default' }) + + // Networking: full air-gap supersedes IPv6 tuning. + if (set.has('network-none')) { + effects.push({ kind: 'network-none' }) + } else if (set.has('disable-ipv6')) { + effects.push({ kind: 'sysctl', key: 'net.ipv6.conf.all.disable_ipv6', value: '1' }) + effects.push({ kind: 'sysctl', key: 'net.ipv6.conf.default.disable_ipv6', value: '1' }) + } + + if (set.has('secure-dns')) { + effects.push({ kind: 'dns', servers: ['1.1.1.1', '1.0.0.1'] }) + } + + for (const tier of Object.keys(RESOURCE_TIERS)) { + if (set.has(tier as HardeningKey)) { + const t = RESOURCE_TIERS[tier]! + effects.push({ kind: 'resources', memory: t.memory, cpus: t.cpus }) + } + } + + if (set.has('vscode-security')) { + effects.push({ + kind: 'noop', + source: 'vscode-security', + reason: 'Editor hardening has no effect in shell-first mode (no devcontainer/VS Code integration).', + }) + } + + return dedupeTmpfs(effects) +} diff --git a/packages/core/src/hardening/flag-emitters.ts b/packages/core/src/hardening/flag-emitters.ts new file mode 100644 index 0000000..7ec0931 --- /dev/null +++ b/packages/core/src/hardening/flag-emitters.ts @@ -0,0 +1,39 @@ +import type { EngineName } from '../engine/types.js' +import type { HardeningEffect } from './effects.js' + +/** + * Render an engine-neutral effect into concrete `run` flags for a given engine. + * Called only after the translator has confirmed the engine supports (or + * caveat-supports) the effect, so this never needs to gate on capabilities. + */ +export function emitFlags(effect: HardeningEffect, _engine: EngineName): string[] { + switch (effect.kind) { + case 'readonly-rootfs': + return ['--read-only'] + case 'tmpfs': + return ['--tmpfs', `${effect.target}:${effect.opts}`] + case 'ephemeral-workspace': + return ['--tmpfs', '/workspace:rw,nosuid,nodev,size=2g,uid=1000,gid=1000,mode=1777'] + case 'drop-cap': + return [`--cap-drop=${effect.cap}`] + case 'no-new-privs': + return ['--security-opt', 'no-new-privileges:true'] + case 'apparmor': + return ['--security-opt', `apparmor=${effect.profile}`] + case 'network-none': + return ['--network=none'] + case 'sysctl': + return ['--sysctl', `${effect.key}=${effect.value}`] + case 'dns': + return effect.servers.flatMap((s) => ['--dns', s]) + case 'resources': + return ['--memory', effect.memory, '--cpus', effect.cpus] + case 'noop': + return [] + } +} + +/** A tmpfs flag string that maps host uids (needs userns remapping under rootless podman). */ +export function flagNeedsUserns(flag: string): boolean { + return /uid=\d+/.test(flag) +} diff --git a/packages/core/src/hardening/translator.ts b/packages/core/src/hardening/translator.ts new file mode 100644 index 0000000..b42a391 --- /dev/null +++ b/packages/core/src/hardening/translator.ts @@ -0,0 +1,135 @@ +import { StrictHardeningError } from '../errors.js' +import type { EngineCapabilities, EngineName } from '../engine/types.js' +import { capabilityFor, type HardeningEffect } from './effects.js' +import { emitFlags, flagNeedsUserns } from './flag-emitters.js' + +export type WarningLevel = 'caveat' | 'dropped' + +export interface HardeningWarning { + level: WarningLevel + effect: string + message: string +} + +export interface TranslateResult { + /** Engine-correct run flags, in effect order. */ + flags: string[] + warnings: HardeningWarning[] + /** Effects that were dropped because the engine cannot honor them. */ + dropped: HardeningEffect[] + /** Caveated effects whose control may be silently inert (enforced: false). */ + unenforced: HardeningEffect[] +} + +function describe(effect: HardeningEffect): string { + switch (effect.kind) { + case 'tmpfs': + return `tmpfs ${effect.target}` + case 'drop-cap': + return `drop-cap ${effect.cap}` + case 'sysctl': + return `sysctl ${effect.key}` + case 'noop': + return effect.source + default: + return effect.kind + } +} + +/** + * Translate engine-neutral hardening effects into engine-correct run flags, + * degrading gracefully: supported → emit; caveated → emit + advisory; + * unsupported → drop + warning. Pure and fully testable against fake capability + * maps — no daemon required. + */ +export function translate( + effects: HardeningEffect[], + capabilities: EngineCapabilities, + engine: EngineName, +): TranslateResult { + const flags: string[] = [] + const warnings: HardeningWarning[] = [] + const dropped: HardeningEffect[] = [] + const unenforced: HardeningEffect[] = [] + + for (const effect of effects) { + const capKey = capabilityFor(effect) + + // Always-applicable effects (noop) surface an advisory but emit nothing. + if (capKey === null) { + if (effect.kind === 'noop') { + warnings.push({ level: 'caveat', effect: describe(effect), message: effect.reason }) + } + continue + } + + const cap = capabilities[capKey] + if (cap.support === 'unsupported') { + dropped.push(effect) + warnings.push({ + level: 'dropped', + effect: describe(effect), + message: cap.note ?? `${engine} does not support this hardening; it was dropped.`, + }) + continue + } + + flags.push(...emitFlags(effect, engine)) + + if (cap.support === 'caveated') { + if (cap.note) warnings.push({ level: 'caveat', effect: describe(effect), message: cap.note }) + if (cap.enforced === false) unenforced.push(effect) + } + } + + // Rootless Podman needs uid/gid-mapped tmpfs to be remapped via keep-id. + if (engine === 'podman' && flags.some((f) => flagNeedsUserns(f))) { + flags.unshift('--userns=keep-id') + warnings.push({ + level: 'caveat', + effect: 'userNamespaces', + message: 'Added --userns=keep-id so uid/gid-mapped tmpfs mounts work under rootless Podman.', + }) + } + + return { flags, warnings, dropped, unenforced } +} + +/** + * Machine-readable summary of what hardening actually reached the engine. + * + * Human-facing output prints `warnings` via `this.warn`, but `--json` consumers + * (AI agents, CI) see only the return envelope — so every command that starts or + * attaches to a container must surface this, or a dropped control (e.g. an + * air-gap the engine cannot enforce) becomes invisible exactly where it matters + * most. Keep the shape identical across commands. + */ +export interface HardeningReport { + /** Run flags actually passed to the engine. */ + appliedFlags: string[] + warnings: HardeningWarning[] + /** Effect kinds the engine cannot honor at all — these were NOT applied. */ + dropped: string[] + /** Effect kinds emitted but possibly inert (engine reports them as unenforced). */ + unenforced: string[] +} + +/** Build the machine-readable hardening summary for a `--json` response. */ +export function hardeningReport(result: TranslateResult, appliedFlags: string[]): HardeningReport { + return { + appliedFlags, + warnings: result.warnings, + dropped: result.dropped.map((e) => e.kind), + unenforced: result.unenforced.map((e) => e.kind), + } +} + +/** Throw under --strict if any hardening was dropped or may be silently inert. */ +export function enforceStrict(result: TranslateResult): void { + const blocking = [...result.dropped, ...result.unenforced] + if (blocking.length === 0) return + const list = blocking.map((e) => describe(e)).join(', ') + throw new StrictHardeningError( + `--strict: the selected engine cannot honor: ${list}. Choose a different engine or remove these options.`, + ) +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1ef1d69..e401b19 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,172 +1 @@ -import { Command, Args, Flags } from '@oclif/core' -import { prebuiltList } from '@/core/devcontainer/prebuiltList' -import { wizard } from '@/core/wizard' -import { generateDevEnvironment } from '@/core/scripts/generate_dev_env' -import { selectWithTopDescription } from '@/ui/components/selectWithTopDescription' -import { colorize, symbols } from '@/ui/components' -import { ui } from '@/ui/styling/ui' -import { checkForUpdates } from '@/utils/versionCheck' - -export default class DevcontainerWizard extends Command { - - static override description = 'DevContainer Wizard - Create custom containers or use pre-built ones' - - static override examples = [ - '$ devcontainer-wizard create', - '$ devcontainer-wizard prebuilt', - '$ devcontainer-wizard create --name my-project' - ] - - static override args = { - action: Args.string({ - description: 'Action to perform: create (custom container) or prebuilt (use pre-built container)', - required: false, - options: ['create', 'prebuilt'], - }), - } - - static override flags = { - name: Flags.string({ - description: 'Name. For create: project name. For prebuilt: prebuilt ID.', - char: 'N', - }), - list: Flags.boolean({ - description: 'List available prebuilt containers and exit', - char: 'L', - default: false, - }), - } - - public async run(): Promise { - const { args, flags } = await this.parse() - - try { - - - if (args.action === 'prebuilt') { - if (flags.list) { - await prebuiltList({ listOnly: true }); - return; - } - await this.runPrebuiltFlow(flags.name); - } else if (args.action === 'create') { - await this.runCreateFlow(flags.name); - } else { - await this.runMainMenu(flags.name); - } - } catch (error) { - if (error instanceof Error && (error.message === 'User force closed the prompt with SIGINT' || error.message === 'User force closed the prompt with SIGTERM')) { - this.log('\nExited with CTRL+C 👋') - process.exit(0) - } else { - this.error(`Failed to start wizard: ${error instanceof Error ? error.message : String(error)}`) - } - } - } - - private async runMainMenu(name?: string): Promise { - const BACK = Symbol.for('back'); - while (true) { - ui.clearScreen() - this.log(colorize.brand(` - ╭───────────────────────────────────────────╮ - │ ✻ Welcome to Devcontainer Wizard 🪷 │ - ╰───────────────────────────────────────────╯ - `)); - - // Check for updates (non-blocking, fails silently) - const updateInfo = await checkForUpdates().catch(() => null); - if (updateInfo && updateInfo.hasUpdate) { - this.log(colorize.warning(` ${symbols.star} A new version is available!`)) - this.log(colorize.muted(` Current: ${updateInfo.currentVersion} ${symbols.arrow} Latest: ${updateInfo.latestVersion}`)) - this.log(colorize.muted(` Run: ${colorize.accent(updateInfo.updateCommand)}`)) - this.log('') - } - - const selected = await selectWithTopDescription({ - message: 'You can select a pre-built container or create your own', - choices: [ - { name: 'Create a custom container', value: 'custom'}, - { name: 'Use a pre-built container', value: 'pre-built' }, - ], - footer: { back: false, exit: true }, - allowBack: false, - }) - - if (selected === 'pre-built') { - const result = await this.runPrebuiltFlow(); - if (result === Symbol.for('back')) { - continue; - } - break; - } else { - await this.runCreateFlow(name); - break; - } - } - } - - private async runPrebuiltFlow(selectedName?: string): Promise { - // Check for updates once at the start (non-blocking, fails silently) - const updateInfo = await checkForUpdates().catch(() => null); - - while (true) { - try { - ui.clearScreen() - - if (updateInfo && updateInfo.hasUpdate) { - this.log(colorize.warning(`${symbols.star} A new version is available!`)) - this.log(colorize.muted(` Current: ${updateInfo.currentVersion} ${symbols.arrow} Latest: ${updateInfo.latestVersion}`)) - this.log(colorize.muted(` Run: ${colorize.accent(updateInfo.updateCommand)}`)) - this.log('') - } - - const selection = await prebuiltList({ selected: selectedName }); - if (selection === Symbol.for('back')) { - return Symbol.for('back'); - } - break; // Exit the loop after successful completion - } catch (error) { - if (error instanceof Error && (error.message === 'User force closed the prompt with SIGINT' || error.message === 'User force closed the prompt with SIGTERM')) { - this.log('\nExited with CTRL+C 👋') - process.exit(0) - } else { - throw error; - } - } - } - } - - private async runCreateFlow(name?: string): Promise { - try { - ui.clearScreen() - - // Check for updates (non-blocking, fails silently) - const updateInfo = await checkForUpdates().catch(() => null); - if (updateInfo && updateInfo.hasUpdate) { - this.log(colorize.warning(`${symbols.star} A new version is available!`)) - this.log(colorize.muted(` Current: ${updateInfo.currentVersion} ${symbols.arrow} Latest: ${updateInfo.latestVersion}`)) - this.log(colorize.muted(` Run: ${colorize.accent(updateInfo.updateCommand)}`)) - this.log('') - } - - const wizardState = await wizard({ name: name || undefined }) - console.log('') - - ui.clearScreen() - await generateDevEnvironment({ config: wizardState }) - console.log('') - - console.log(colorize.success(symbols.check + ' Devcontainer creation completed successfully!')) - console.log('') - - } catch (error) { - if (error instanceof Error && (error.message === 'User force closed the prompt with SIGINT' || error.message === 'User force closed the prompt with SIGTERM')) { - this.log('\nExited with CTRL+C 👋') - process.exit(0) - } else { - this.error(`Failed to create devcontainer: ${error instanceof Error ? error.message : String(error)}`) - } - } - } -} +export { BaseCommand } from './base-command.js' diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts new file mode 100644 index 0000000..4676708 --- /dev/null +++ b/packages/core/src/skill.ts @@ -0,0 +1,22 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** + * Path to the packaged agent skill (`skill/SKILL.md`). + * + * Resolved relative to this module so it works both from `src/` (tsx dev + * entry) and `dist/` (built) — each sits one level below the package root. + */ +export const SKILL_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', 'skill', 'SKILL.md') + +/** Read the raw SKILL.md markdown (frontmatter included). */ +export function readSkill(): string { + return readFileSync(SKILL_PATH, 'utf8') +} + +/** Parse the `name:` from the skill frontmatter, used to suggest an install path. */ +export function skillName(markdown: string): string { + const match = /^---\n[\s\S]*?^name:\s*(\S+)\s*$/m.exec(markdown) + return match?.[1] ?? 'dcw' +} diff --git a/packages/core/src/spec/env-spec.ts b/packages/core/src/spec/env-spec.ts new file mode 100644 index 0000000..0c49e1f --- /dev/null +++ b/packages/core/src/spec/env-spec.ts @@ -0,0 +1,76 @@ +import { z } from 'zod' +import { validValuesFor } from '../domain/catalog.js' +import { HARDENING_KEYS } from '../domain/hardening.js' +import { ALL_ENGINES } from '../engine/types.js' + +function enumArray(values: string[]) { + return z.array(z.enum(values as [string, ...string[]])) +} + +/** + * A git remote: either a scheme URL (http/https/git/ssh) or scp-style + * `user@host:path`. The character class deliberately excludes whitespace and + * shell metacharacters so the value can't break out of the `RUN git clone` + * line in the generated Containerfile (build-time RCE). Scheme URLs also + * restrict `@`: `https://user:token@host/...` userinfo would be persisted in + * cleartext into the manifest, the Containerfile and the image's + * remote.origin.url, so http(s) forbids `@` outright. `ssh://` and `git://` + * allow a bare `user@` login (the canonical `ssh://git@github.com/o/r.git` + * form this error message itself recommends) but not `user:password@`, since + * the userinfo pattern excludes `:`. The `user@` in scp-style remotes is an + * SSH login, not a secret, and is likewise allowed. + */ +// `%` is excluded from every branch: git percent-decodes URL userinfo, so +// `ssh://user%3Apass%40host/repo` passes a naive character check and is then handed +// to ssh as `user:pass@host` — smuggling the credentials the rules below forbid +// straight into the manifest and the generated Containerfile. No legitimate remote +// dcw supports needs percent-encoding. +const GIT_URL_RE = + /^(?:https?:\/\/[^\s;`$(){}<>|&'"\\@%]+|(?:git|ssh):\/\/(?:[A-Za-z0-9._-]+@)?[^\s;`$(){}<>|&'"\\@%]+|[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:[A-Za-z0-9._/~-]+)$/ +/** A safe git ref (branch/tag): no whitespace, no leading dash, no metacharacters. */ +const GIT_BRANCH_RE = /^(?!-)[A-Za-z0-9._/-]+$/ + +export const GitRepositorySchema = z.object({ + url: z + .string() + .min(1) + .regex(GIT_URL_RE, 'must be an http(s)/git/ssh URL or scp-style remote with no spaces, shell metacharacters or embedded credentials (user:token@host); use SSH or a credential helper for private repos'), + branch: z + .string() + .regex(GIT_BRANCH_RE, 'must be a valid git ref (letters, digits, ., _, /, -; no leading dash)') + .optional(), + enabled: z.boolean(), +}) + +export const SelectionsSchema = z + .object({ + coreLanguages: enumArray(validValuesFor('coreLanguages')).optional(), + languages: enumArray(validValuesFor('languages')).optional(), + frameworks: enumArray(validValuesFor('frameworks')).optional(), + fuzzingAndTesting: enumArray(validValuesFor('fuzzingAndTesting')).optional(), + securityTooling: enumArray(validValuesFor('securityTooling')).optional(), + aiAgents: enumArray(validValuesFor('aiAgents')).optional(), + }) + .strict() + +/** + * The authored environment specification — produced identically by the ink + * wizard and the non-interactive flag path, and persisted inside the manifest. + */ +export const EnvSpecSchema = z + .object({ + name: z.string().min(1), + engine: z.enum(['auto', ...ALL_ENGINES] as [string, ...string[]]).default('auto'), + selections: SelectionsSchema.default({}), + /** Normalized hardening keys (after any profile expansion). */ + hardening: enumArray(HARDENING_KEYS).default([]), + /** Profile(s) the hardening was derived from, for display only. */ + profile: z.string().optional(), + gitRepository: GitRepositorySchema.optional(), + /** Bake an SSH server into the image for editor attach (`dcw attach`). */ + ssh: z.boolean().default(true), + }) + .strict() + +export type EnvSpec = z.infer +export type GitRepository = z.infer diff --git a/packages/core/src/spec/flags-to-spec.ts b/packages/core/src/spec/flags-to-spec.ts new file mode 100644 index 0000000..d52fffe --- /dev/null +++ b/packages/core/src/spec/flags-to-spec.ts @@ -0,0 +1,114 @@ +import { ValidationError } from '../errors.js' +import { DEFAULT_PROFILE, NO_PROFILE, isProfileKey, recipesToHardening } from '../domain/profiles.js' +import { normalizeHardening } from '../domain/normalize.js' +import { isValidEnvName, slugify } from '../util/slug.js' +import { EnvSpecSchema, type EnvSpec } from './env-spec.js' + +export interface FlagInput { + name?: string + coreLanguages?: string[] + languages?: string[] + frameworks?: string[] + fuzzingAndTesting?: string[] + securityTooling?: string[] + aiAgents?: string[] + /** Named security profile to expand (e.g. 'hardened'). */ + profile?: string + /** Manual hardening keys, merged with the profile expansion. */ + hardening?: string[] + engine?: string + gitUrl?: string + gitBranch?: string + /** Bake an SSH server for editor attach (default true when omitted). */ + ssh?: boolean + /** Fallback name (e.g. cwd basename) when --name is omitted. */ + fallbackName?: string +} + +/** Drop duplicate values while preserving first-seen order. */ +function dedupe(values: string[]): string[] { + return [...new Set(values)] +} + +function compactSelections(input: FlagInput) { + const sel: Record = {} + if (input.coreLanguages?.length) sel.coreLanguages = dedupe(input.coreLanguages) + if (input.languages?.length) sel.languages = dedupe(input.languages) + if (input.frameworks?.length) sel.frameworks = dedupe(input.frameworks) + if (input.fuzzingAndTesting?.length) sel.fuzzingAndTesting = dedupe(input.fuzzingAndTesting) + if (input.securityTooling?.length) sel.securityTooling = dedupe(input.securityTooling) + if (input.aiAgents?.length) sel.aiAgents = dedupe(input.aiAgents) + return sel +} + +/** + * Build a validated EnvSpec from non-interactive flag inputs. Expands a profile + * (if given) and merges manual hardening keys, then validates everything against + * the EnvSpec schema (catalog/hardening enums), raising a ValidationError on + * any unknown value. + */ +export function flagsToSpec(input: FlagInput): EnvSpec { + const name = (input.name ?? input.fallbackName ?? '').trim() + if (!name) { + throw new ValidationError('An environment name is required (pass --name).') + } + + const slug = slugify(name) + if (!isValidEnvName(slug)) { + // A derived name (cwd basename) that slugifies to a hidden/dot value — e.g. + // running `create --no-input` inside a `.config` directory — can't be fixed by + // the user except by naming the env explicitly, so point them at --name. + const derived = input.name === undefined || input.name.trim() === '' + if (derived && slug.startsWith('.')) { + throw new ValidationError( + `Derived environment name '${slug}' is not usable as an identifier (hidden/dot name); pass --name explicitly.`, + ) + } + throw new ValidationError( + `Environment name '${name}' is not usable as an identifier; use up to 63 characters including letters or digits.`, + ) + } + + if (input.profile && input.profile !== NO_PROFILE && !isProfileKey(input.profile)) { + throw new ValidationError(`Unknown profile '${input.profile}'. Use '${NO_PROFILE}' to opt out of hardening.`) + } + + if (input.gitBranch && !input.gitUrl) { + throw new ValidationError('--git-branch requires --git-url.') + } + + // Absence of any choice means the DEFAULT posture, not "no hardening": a bare + // `dcw create` used to produce an environment with no capability drops, no + // no-new-privileges and no secure tmpfs — and `--strict` passed vacuously, + // because nothing had been requested to fail. Naming `--harden` keys is itself a + // deliberate choice, so the default only applies when nothing at all was given; + // `--profile none` is the explicit opt-out. + const choseNothing = !input.profile && (input.hardening ?? []).length === 0 + const effectiveProfile = choseNothing ? DEFAULT_PROFILE : input.profile + + const fromProfile = + effectiveProfile && effectiveProfile !== NO_PROFILE ? recipesToHardening([effectiveProfile]) : [] + const { keys: hardening, unknown } = normalizeHardening([...fromProfile, ...(input.hardening ?? [])]) + if (unknown.length > 0) { + throw new ValidationError(`Unknown hardening option(s): ${unknown.join(', ')}.`) + } + + const candidate = { + name: slug, + engine: input.engine ?? 'auto', + selections: compactSelections(input), + hardening, + profile: effectiveProfile, + gitRepository: input.gitUrl + ? { url: input.gitUrl, branch: input.gitBranch, enabled: true } + : undefined, + ssh: input.ssh ?? true, + } + + const result = EnvSpecSchema.safeParse(candidate) + if (!result.success) { + const issue = result.error.issues[0] + throw new ValidationError(`Invalid selection: ${issue?.path.join('.')} — ${issue?.message}.`) + } + return result.data +} diff --git a/packages/core/src/spec/schema-doc.ts b/packages/core/src/spec/schema-doc.ts new file mode 100644 index 0000000..079276a --- /dev/null +++ b/packages/core/src/spec/schema-doc.ts @@ -0,0 +1,40 @@ +import { zodToJsonSchema } from 'zod-to-json-schema' +import { CATALOG } from '../domain/catalog.js' +import { HARDENING_OPTIONS } from '../domain/hardening.js' +import { PROFILES } from '../domain/profiles.js' +import { ALL_ENGINES } from '../engine/types.js' +import { EnvSpecSchema } from './env-spec.js' + +/** + * Self-describing capability document for agents: the EnvSpec JSON Schema plus + * the full option vocabulary (catalog, profiles, hardening, engines). A single + * source — derived from the same zod schema + registries the CLI enforces. + */ +export function buildSchemaDoc(version: string) { + return { + version, + envSpec: zodToJsonSchema(EnvSpecSchema, 'EnvSpec'), + catalog: CATALOG.map((c) => ({ + key: c.key, + title: c.title, + multi: c.multi, + options: c.items.map((i) => ({ value: i.value, label: i.label, description: i.description })), + })), + profiles: PROFILES.map((p) => ({ + key: p.key, + label: p.label, + description: p.description, + caveat: p.caveat, + experimental: p.experimental, + })), + hardening: HARDENING_OPTIONS.map((h) => ({ + key: h.key, + label: h.label, + description: h.description, + category: h.category, + })), + engines: ALL_ENGINES, + } +} + +export type SchemaDoc = ReturnType diff --git a/packages/core/src/state/manifest.ts b/packages/core/src/state/manifest.ts new file mode 100644 index 0000000..7f78c0c --- /dev/null +++ b/packages/core/src/state/manifest.ts @@ -0,0 +1,83 @@ +import { z } from 'zod' +import { EnvSpecSchema } from '../spec/env-spec.js' +import { ALL_ENGINES } from '../engine/types.js' + +export const SCHEMA_VERSION = 1 + +export const ToolStatusSchema = z.object({ name: z.string(), ok: z.boolean() }) + +export const ImageStateSchema = z.object({ + tag: z.string(), + imageId: z.string().optional(), + /** sha256 of the generated Containerfile → staleness detection. */ + containerfileHash: z.string(), + builtAt: z.string().optional(), + /** Per-tool best-effort install results read back from the image. */ + tools: z.array(ToolStatusSchema).optional(), +}) + +/** How an environment was wired for editor attach (`dcw attach`). */ +export const SshStateSchema = z.object({ + /** 'exec' = ProxyCommand over engine exec (no ports); 'port' = published TCP port. */ + mode: z.enum(['exec', 'port']), + /** Published host port, when mode === 'port'. */ + port: z.number().int().positive().optional(), +}) + +export const ContainerStateSchema = z.object({ + id: z.string().optional(), + name: z.string(), + status: z.enum(['running', 'stopped', 'unknown']).optional(), + startedAt: z.string().optional(), + /** Exact run flags used (reproducibility/debug). */ + appliedFlags: z.array(z.string()).optional(), + /** Hardening that degraded on the chosen engine (requested but NOT applied). */ + droppedHardening: z.array(z.string()).optional(), + /** Hardening that was emitted but the engine may not actually enforce (e.g. + * AppArmor on a Docker VM with no LSM). Distinct from dropped: the flag IS on + * the command line, it just may do nothing — `--strict` must reject both. */ + unenforcedHardening: z.array(z.string()).optional(), + /** Editor-attach wiring, set by `dcw attach`. */ + ssh: SshStateSchema.optional(), +}) + +export const EnvManifestSchema = z.object({ + schemaVersion: z.literal(SCHEMA_VERSION), + name: z.string().min(1), + createdAt: z.string(), + updatedAt: z.string(), + spec: EnvSpecSchema, + resolved: z.object({ + requiredTools: z.array(z.string()), + hardeningKeys: z.array(z.string()), + }), + engine: z.enum(ALL_ENGINES as unknown as [string, ...string[]]).nullable(), + image: ImageStateSchema.nullable(), + container: ContainerStateSchema.nullable(), +}) + +export type EnvManifest = z.infer +export type ImageState = z.infer +export type ContainerState = z.infer +export type SshState = z.infer +export type ToolStatus = z.infer + +/** Just the version envelope, parsed before the full (version-locked) schema so we + * can tell "older manifest we can migrate" from "newer dcw wrote this". */ +export const SchemaVersionSchema = z.object({ schemaVersion: z.number().int().positive() }) + +/** + * Migrate a raw manifest object (already known to have schemaVersion <= current) + * forward to the current shape, then validate it. Only v1 exists today, so this + * is an identity migration; the `switch` is the seam where future per-version + * upgrade steps are added (e.g. `case 1: raw = v1ToV2(raw)`). + */ +export function migrateManifest(raw: unknown, fromVersion: number): EnvManifest { + let migrated = raw + switch (fromVersion) { + // case 1: migrated = v1ToV2(migrated); // fall through as versions are added + default: + break + } + return EnvManifestSchema.parse(migrated) +} diff --git a/packages/core/src/state/paths.ts b/packages/core/src/state/paths.ts new file mode 100644 index 0000000..74b6097 --- /dev/null +++ b/packages/core/src/state/paths.ts @@ -0,0 +1,37 @@ +import os from 'node:os' +import path from 'node:path' + +const APP = 'dcw' + +/** XDG config root ($XDG_CONFIG_HOME or ~/.config). Read lazily so tests can override. */ +export function configHome(): string { + return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config') +} + +/** XDG state root ($XDG_STATE_HOME or ~/.local/state). */ +export function stateHome(): string { + return process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state') +} + +/** Directory holding per-environment manifests. */ +export function environmentsDir(): string { + return path.join(configHome(), APP, 'environments') +} + +export function manifestPath(name: string): string { + return path.join(environmentsDir(), `${name}.json`) +} + +/** Per-environment derived state (generated Containerfile, build context). */ +export function envStateDir(name: string): string { + return path.join(stateHome(), APP, name) +} + +/** Directory holding dcw's own SSH keypair + known_hosts used for editor attach. */ +export function sshDir(): string { + return path.join(configHome(), APP, 'ssh') +} + +export function containerfilePath(name: string): string { + return path.join(envStateDir(name), 'Containerfile') +} diff --git a/packages/core/src/state/store.ts b/packages/core/src/state/store.ts new file mode 100644 index 0000000..d9cfc5f --- /dev/null +++ b/packages/core/src/state/store.ts @@ -0,0 +1,126 @@ +import { createHash, randomBytes } from 'node:crypto' +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import { ValidationError } from '../errors.js' +import { EnvManifestSchema, SCHEMA_VERSION, SchemaVersionSchema, migrateManifest, type EnvManifest } from './manifest.js' +import { containerfilePath, environmentsDir, envStateDir, manifestPath } from './paths.js' + +/** sha256 of Containerfile content (hex), used for build-staleness checks. */ +export function hashContainerfile(content: string): string { + return createHash('sha256').update(content, 'utf8').digest('hex') +} + +/** Owner-only modes for dcw state. Manifests record `appliedFlags`, which embed the + * absolute workspace path, repo URL and tooling choices — not other local accounts' + * business — so state is kept private rather than inheriting a 022 umask (0644/0755). */ +const DIR_MODE = 0o700 +const FILE_MODE = 0o600 + +async function atomicWrite(filePath: string, content: string): Promise { + const dir = path.dirname(filePath) + await fs.mkdir(dir, { recursive: true, mode: DIR_MODE }) + // `mode` on mkdir applies only to directories it CREATES (and is umask-masked), so + // a dcw dir left 0755 by an older version would stay world-readable forever. + await fs.chmod(dir, DIR_MODE).catch(() => undefined) + const tmp = `${filePath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp` + await fs.writeFile(tmp, content, { mode: FILE_MODE }) + // `mode` on writeFile is masked by the umask and ignored if the temp file were to + // pre-exist; chmod before the rename so the published file is always 0600 — and so + // rewriting an env whose manifest was previously world-readable tightens it. + await fs.chmod(tmp, FILE_MODE) + await fs.rename(tmp, filePath) +} + +export async function saveManifest(manifest: EnvManifest): Promise { + const parsed = EnvManifestSchema.parse(manifest) + await atomicWrite(manifestPath(parsed.name), `${JSON.stringify(parsed, null, 2)}\n`) +} + +/** Load a manifest by name; returns null if it does not exist. */ +export async function loadManifest(name: string): Promise { + let raw: string + try { + raw = await fs.readFile(manifestPath(name), 'utf8') + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null + throw err + } + let json: unknown + try { + json = JSON.parse(raw) + } catch { + throw new ValidationError(`Manifest for '${name}' is not valid JSON.`) + } + + // Read the version envelope first so a future SCHEMA_VERSION bump degrades + // gracefully: older manifests are migrated forward (so `ls` never loses them), + // and a manifest written by a newer dcw is rejected with a clear message rather + // than silently failing the version-locked schema. + const ver = SchemaVersionSchema.safeParse(json) + if (!ver.success) { + throw new ValidationError(`Manifest for '${name}' is missing a valid schemaVersion.`) + } + if (ver.data.schemaVersion > SCHEMA_VERSION) { + throw new ValidationError( + `Manifest for '${name}' was written by a newer dcw (schemaVersion ${ver.data.schemaVersion} > ${SCHEMA_VERSION}); upgrade dcw to use it.`, + ) + } + + try { + const parsed = migrateManifest(json, ver.data.schemaVersion) + // Commands resolve an environment by FILENAME but then act on the manifest's + // inner `name` (image tag, container name, state dir). A mismatch therefore + // lets `dcw build outer` build and persist state for `inner`, silently + // clobbering another environment's namespace. listManifests() already skips + // these; refuse to hand one back here too. + if (parsed.name !== name) { + throw new ValidationError( + `Manifest file '${name}.json' declares a different environment name ('${parsed.name}'); refusing to use it.`, + ) + } + return parsed + } catch (err) { + if (err instanceof ValidationError) throw err + const issue = (err as { issues?: { message?: string }[] }).issues?.[0]?.message + throw new ValidationError(`Manifest for '${name}' is invalid: ${issue ?? 'unknown'}.`) + } +} + +/** List all valid manifests, skipping unreadable/invalid ones. */ +export async function listManifests(): Promise { + let files: string[] + try { + files = await fs.readdir(environmentsDir()) + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw err + } + const out: EnvManifest[] = [] + for (const file of files) { + if (!file.endsWith('.json')) continue + const name = file.slice(0, -'.json'.length) + try { + const m = await loadManifest(name) + // Commands key off the filename, so a manifest whose internal `name` does + // not match its filename is unreachable by that name — skip it rather than + // list an env that no command can act on (consistent with how invalid + // manifests are skipped here). + if (m && m.name === name) out.push(m) + } catch { + // skip invalid manifests in listings + } + } + return out.sort((a, b) => a.name.localeCompare(b.name)) +} + +export async function removeEnvironment(name: string): Promise { + await fs.rm(manifestPath(name), { force: true }) + await fs.rm(envStateDir(name), { recursive: true, force: true }) +} + +/** Write the generated Containerfile into the env's state dir; returns its path. */ +export async function writeContainerfile(name: string, content: string): Promise { + const target = containerfilePath(name) + await atomicWrite(target, content) + return target +} diff --git a/packages/core/src/types.d.ts b/packages/core/src/types.d.ts deleted file mode 100644 index 964e4fa..0000000 --- a/packages/core/src/types.d.ts +++ /dev/null @@ -1,38 +0,0 @@ -type ConfirmTheme = { - prefix: string | { idle: string; done: string }; - spinner: { - interval: number; - frames: string[]; - }; - style: { - answer: (text: string) => string; - message: (text: string, status: 'idle' | 'done' | 'loading') => string; - defaultAnswer: (text: string) => string; - }; -}; - -export type Runtime = 'rust' | 'python' | 'go' | 'node' | 'uv' | 'asdf' | 'pnpm'; - -export type Selection = { - languages: string[]; - frameworks: string[]; - fuzzingAndTesting: string[]; - securityTooling: string[]; -}; - -export type WizardState = { - coreLanguages?: string[] - languages?: string[] - frameworks?: string[] - fuzzingAndTesting?: string[] - securityTooling?: string[] - name?: string - vscodeExtensions?: string[] - savePath?: string - systemHardening?: string[] - gitRepository?: { - url: string - branch?: string - enabled: boolean - } -} \ No newline at end of file diff --git a/packages/core/src/ui/components/checkboxWithTopDescription.ts b/packages/core/src/ui/components/checkboxWithTopDescription.ts deleted file mode 100644 index b4bb1f3..0000000 --- a/packages/core/src/ui/components/checkboxWithTopDescription.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { - createPrompt, - useState, - useKeypress, - isUpKey, - isDownKey, - isSpaceKey, - isEnterKey, - usePagination, -} from '@inquirer/core'; -import { Separator } from '@inquirer/prompts'; -import { symbols } from '@/ui/styling/symbols'; -import { colorize } from '@/ui/styling/colors'; -import { ui } from '../styling/ui'; - -type RawChoice = - | string - | { name?: string; value?: T; description?: string; disabled?: boolean; checked?: boolean } - | Separator; - -type PromptConfig = { - message?: string; - choices?: RawChoice[]; - footer?: { - back?: boolean; - exit?: boolean; - }; - allowBack?: boolean; -}; - -function normalizeChoices(raw: RawChoice[]) { - return raw.map((c, idx) => { - if (c instanceof Separator) { - - const separatorText = (c as any).line || - (c as any).separator || - (c as any).name || - (c as any).value || - Object.values(c)[0] || - String(c) || - '--------'; - return { isSeparator: true, name: separatorText, value: undefined, description: undefined, disabled: true, checked: false }; - } - return typeof c === 'string' - ? { name: c, value: c as unknown as T, description: undefined, disabled: false, checked: false, isSeparator: false } - : { - name: c.name ?? (c.value as any)?.toString() ?? `choice ${idx}`, - value: c.value ?? (c.name as any), - description: c.description, - disabled: !!c.disabled, - checked: !!c.checked, - isSeparator: false, - }; - }); -} - -export const checkboxWithTopDescription: any = createPrompt((config: PromptConfig, done: (res: any) => void) => { - const raw = config.choices ?? []; - const choices = normalizeChoices(raw); - - process.stdout.write('\x1B[?25l'); - - const firstIndex = choices.findIndex((c) => !c.disabled && !c.isSeparator); - const [index, setIndex] = useState(firstIndex === -1 ? 0 : 0); - const [isDone, setIsDone] = useState(false); - const initialChecked = new Set(); - choices.forEach((c, i) => { - if (c.checked) initialChecked.add(i); - }); - const [checkedSet, setCheckedSet] = useState>(initialChecked); - - const pagination = usePagination({ - items: choices, - active: index, - renderItem: ({ item, isActive, index: itemIndex }) => { - if (item.isSeparator) { - return `${colorize.muted(symbols.separatorIndent.repeat(3) + item.name)}`; - } - const pointer = isActive ? colorize.brand(symbols.pointer) : ' '; - const box = item.disabled - ? colorize.muted(symbols.checkbox.disabled) - : checkedSet.has(itemIndex) - ? colorize.brand(symbols.checkbox.checked) - : colorize.brand(symbols.checkbox.unchecked); - const name = item.name ?? String(item.value); - const disabledTag = item.disabled ? colorize.muted(' (disabled)') : ''; - const styledName = isActive ? colorize.highlight(name) : name; - return `${pointer} ${box} ${styledName}${disabledTag}`; - }, - pageSize: 10, - loop: false, - }); - - useKeypress((key: any) => { - if (isDone) return; - - if (isUpKey(key)) { - let i = index - 1; - while (i >= 0 && (choices[i].disabled || choices[i].isSeparator)) i -= 1; - if (i >= 0) { - setIndex(i); - } - return; - } - if (isDownKey(key)) { - let i = index + 1; - while (i < choices.length && (choices[i].disabled || choices[i].isSeparator)) i += 1; - if (i < choices.length) { - setIndex(i); - } - return; - } - - if (key.name === 'left') { - return; - } - - if (key.name === 'escape' && config.allowBack) { - done(Symbol.for('back')); - return; - } - - if (isSpaceKey(key) || key.name === 'right') { - if (choices[index] && !choices[index].disabled && !choices[index].isSeparator) { - const nextCheckedSet = new Set(checkedSet); - if (nextCheckedSet.has(index)) { - nextCheckedSet.delete(index); - } else { - nextCheckedSet.add(index); - } - setCheckedSet(nextCheckedSet); - } - return; - } - if (isEnterKey(key)) { - setIsDone(true); - const result = Array.from(checkedSet) - .sort((a, b) => a - b) - .map((i) => choices[i].value); - done(result); - return; - } - }); - - if (isDone) { - process.stdout.write('\x1B[25h'); - const selectedCount = checkedSet.size; - return `${colorize.brand(symbols.bullet)} ${config.message ?? ''}\n\n ${colorize.success(symbols.check)} ${selectedCount} selected`; - } - - const msg = config.message ?? ''; - const current = choices[index]; - const currentDesc = current?.description && !current.isSeparator ? `${colorize.muted(current.description)}\n` : ''; - - return `${colorize.brand(symbols.bullet)} ${colorize.brand(msg)}\n\n${currentDesc}${pagination} \n\n${ui.footer(config.footer?.back ?? false, config.footer?.exit ?? true)}`; -}); \ No newline at end of file diff --git a/packages/core/src/ui/components/confirmWithFooter.ts b/packages/core/src/ui/components/confirmWithFooter.ts deleted file mode 100644 index 7c38ce9..0000000 --- a/packages/core/src/ui/components/confirmWithFooter.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { - createPrompt, - useState, - useKeypress, - isEnterKey, -} from '@inquirer/core'; -import { symbols } from '@/ui/styling/symbols'; -import { colorize } from '@/ui/styling/colors'; -import { ui } from '@/ui/styling/ui'; - -type PromptConfig = { - message?: string; - default?: boolean; - theme?: any; - footer?: { - back?: boolean; - exit?: boolean; - }; - allowBack?: boolean; -}; - -export const confirmWithFooter: any = createPrompt((config: PromptConfig, done: (res: any) => void) => { - const [value, setValue] = useState(config.default ?? true); - const [isDone, setIsDone] = useState(false); - - process.stdout.write('\x1B[?25l'); - - useKeypress((key: any) => { - if (isDone) return; - - if (key.name === 'left' || key.name === 'right') { - setValue(!value); - return; - } - - if (key.name === 'y' || key.name === 'Y') { - setValue(true); - return; - } - - if (key.name === 'n' || key.name === 'N') { - setValue(false); - return; - } - - if (key.name === 'escape' && config.allowBack) { - process.stdout.write('\x1B[?25h'); - done(Symbol.for('back')); - return; - } - - if (isEnterKey(key)) { - setIsDone(true); - done(value); - return; - } - }); - - if (isDone) { - process.stdout.write('\x1B[?25h'); - const answer = value ? 'Yes' : 'No'; - return `${colorize.brand(symbols.bullet)} ${config.message ?? ''}\n\n ${colorize.success(symbols.check)} ${colorize.brand(answer)}`; - } - - const msg = config.message ?? ''; - const defaultHint = config.default !== undefined ? - (config.default ? ' (Y/n)' : ' (y/N)') : ' (y/N)'; - const answer = value ? colorize.brand('Yes') : colorize.brand('No'); - - return `${colorize.brand(symbols.bullet)} ${colorize.brand(msg)}${colorize.muted(defaultHint)} ${answer}\n\n${ui.footer(config.footer?.back ?? false, config.footer?.exit ?? true)}`; -}); diff --git a/packages/core/src/ui/components/index.ts b/packages/core/src/ui/components/index.ts deleted file mode 100644 index 9caac9d..0000000 --- a/packages/core/src/ui/components/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export {checkboxWithTopDescription} from "./checkboxWithTopDescription"; -export {selectWithTopDescription} from "./selectWithTopDescription"; -export {inputWithSymbols} from "./inputWithSymbols"; -export {confirmWithFooter} from "./confirmWithFooter"; -export { ui } from "../styling/ui"; -export { colorize } from "../styling/colors"; -export { symbols } from "../styling/symbols"; \ No newline at end of file diff --git a/packages/core/src/ui/components/inputWithSymbols.ts b/packages/core/src/ui/components/inputWithSymbols.ts deleted file mode 100644 index 760f9de..0000000 --- a/packages/core/src/ui/components/inputWithSymbols.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { - createPrompt, - useState, - useKeypress, - useEffect, - useRef, -} from '@inquirer/core'; -import { symbols } from '@/ui/styling/symbols'; -import { colorize } from '@/ui/styling/colors'; -import { ui } from '../styling/ui'; - -type PromptConfig = { - message?: string; - default?: string; - validate?: (input: string) => true | string | Promise; - footer?: { - back?: boolean; - exit?: boolean; - }; - allowBack?: boolean; -}; - -function isPrintableKey(key: any) { - if (!key) return false; - if (key.ctrl || key.meta) return false; - const seq: string = key.sequence ?? ''; - if (!seq) return false; - // Ignore escape sequences (arrows, etc.) - if (seq.startsWith('\u001b')) return false; - // Filter out non-printable characters - return [...seq].every((ch) => { - const code = ch.codePointAt(0) ?? 0; - return code >= 0x20 && code !== 0x7f; - }); -} - -export const inputWithSymbols: any = createPrompt((config: PromptConfig, done: (res: any) => void) => { - const initial = (config.default ?? '').toString(); - const [value, setValue] = useState(''); - const [cursorPosition, setCursorPosition] = useState(0); - const [hasStartedTyping, setHasStartedTyping] = useState(false); - const [error, setError] = useState(undefined); - const [isDone, setIsDone] = useState(false); - const [showCursor, setShowCursor] = useState(true); - const showCursorRef = useRef(true); - - // Hide system cursor - process.stdout.write('\x1B[?25l'); - - // Blinking cursor effect - useEffect(() => { - if (isDone) return; - - const interval = setInterval(() => { - showCursorRef.current = !showCursorRef.current; - setShowCursor(showCursorRef.current); - }, 500); // Blink every 500ms - - return () => clearInterval(interval); - }, [isDone]); - - useKeypress(async (key: any) => { - if (isDone) return; - - // Handle cursor movement - if (key.name === 'left') { - setCursorPosition(Math.max(0, cursorPosition - 1)); - return; - } - - if (key.name === 'right') { - setCursorPosition(Math.min(value.length, cursorPosition + 1)); - return; - } - - if (key.name === 'home') { - setCursorPosition(0); - return; - } - - if (key.name === 'end') { - setCursorPosition(value.length); - return; - } - - if (key.name === 'backspace' || key.sequence === '\u007f') { - if (!hasStartedTyping) { - // If user hasn't started typing, clear the default and start fresh - setHasStartedTyping(true); - setValue(''); - setCursorPosition(0); - } else if (cursorPosition > 0) { - const newValue = value.slice(0, cursorPosition - 1) + value.slice(cursorPosition); - setValue(newValue); - setCursorPosition(cursorPosition - 1); - } - return; - } - - if (key.name === 'delete') { - if (!hasStartedTyping) { - // If user hasn't started typing, clear the default and start fresh - setHasStartedTyping(true); - setValue(''); - setCursorPosition(0); - } else if (cursorPosition < value.length) { - const newValue = value.slice(0, cursorPosition) + value.slice(cursorPosition + 1); - setValue(newValue); - } - return; - } - - if (key.name === 'up' || key.name === 'down' || key.name === 'tab' || key.name === 'shift' || key.name === 'ctrl' || key.name === 'meta') { - return; - } - - if (key.name === 'escape' && config.allowBack) { - done(Symbol.for('back')); - return; - } - - if (key.name === 'return' || key.name === 'enter') { - - const toValidate = hasStartedTyping ? value : initial; - if (config.validate) { - const res = await config.validate(toValidate); - if (res !== true) { - setError(typeof res === 'string' ? res : 'Invalid input'); - return; - } - } - setIsDone(true); - done(toValidate); - return; - } - - if (isPrintableKey(key)) { - setError(undefined); - if (!hasStartedTyping) { - setHasStartedTyping(true); - setValue(key.sequence ?? ''); - setCursorPosition(1); - } else { - const newValue = value.slice(0, cursorPosition) + (key.sequence ?? '') + value.slice(cursorPosition); - setValue(newValue); - setCursorPosition(cursorPosition + 1); - } - return; - } - }); - - if (isDone) { - process.stdout.write('\x1B[?25h'); - const finalValue = hasStartedTyping ? value : initial; - return `${colorize.brand(symbols.bullet)} ${config.message ?? ''}\n\n ${colorize.success(symbols.check)} ${finalValue}`; - } - - const msg = config.message ?? ''; - const errorText = error ? `\n${colorize.error(error)}` : ''; - - - - // Create display value with cursor - let displayValue: string; - if (hasStartedTyping) { - const beforeCursor = value.slice(0, cursorPosition); - const afterCursor = value.slice(cursorPosition); - const cursorChar = showCursor ? colorize.brand('|') : ' '; // Blinking cursor indicator - displayValue = beforeCursor + cursorChar + afterCursor; - } else { - // Show cursor even before typing starts - const cursorChar = showCursor ? colorize.brand('|') : ' '; // Blinking cursor indicator - if (initial) { - displayValue = colorize.muted(initial) + cursorChar; - } else { - displayValue = cursorChar; - } - } - - return `${colorize.brand(symbols.bullet)} ${colorize.brand(msg)} ${displayValue}${errorText} \n\n${ui.footer(config.footer?.back ?? false, config.footer?.exit ?? true)}`; -}); - - diff --git a/packages/core/src/ui/components/selectWithTopDescription.ts b/packages/core/src/ui/components/selectWithTopDescription.ts deleted file mode 100644 index 1a4e2cb..0000000 --- a/packages/core/src/ui/components/selectWithTopDescription.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { - createPrompt, - useState, - useKeypress, - isUpKey, - isDownKey, - isEnterKey, - usePagination, -} from '@inquirer/core'; -import { Separator } from '@inquirer/prompts'; -import { symbols } from '@/ui/styling/symbols'; -import { colorize } from '@/ui/styling/colors'; -import { ui } from '../styling/ui'; - -type RawChoice = - | string - | { name?: string; value?: T; description?: string; caveat?: string; disabled?: boolean } - | Separator; - -type PromptConfig = { - message?: string; - choices?: RawChoice[]; - footer?: { - back?: boolean; - exit?: boolean; - }; - allowBack?: boolean; -} - -function normalizeChoices(raw: RawChoice[]) { - return raw.map((c, idx) => { - if (c instanceof Separator) { - - const separatorText = (c as any).line || - (c as any).separator || - (c as any).name || - (c as any).value || - Object.values(c)[0] || - String(c) || - '--------'; - return { isSeparator: true, name: separatorText, value: undefined, description: undefined, caveat: undefined, disabled: true }; - } - return typeof c === 'string' - ? { name: c, value: c as unknown as T, description: undefined, caveat: undefined, disabled: false, isSeparator: false } - : { - name: c.name ?? (c.value as any)?.toString() ?? `choice ${idx}`, - value: c.value ?? (c.name as any), - description: c.description, - caveat: c.caveat, - disabled: !!c.disabled, - isSeparator: false, - }; - }); -} - -export const selectWithTopDescription: any = createPrompt((config: PromptConfig, done: (res: any) => void) => { - const raw = config.choices ?? []; - const choices = normalizeChoices(raw); - - process.stdout.write('\x1B[?25l'); - - const firstIndex = choices.findIndex((c) => !c.disabled && !c.isSeparator); - const [index, setIndex] = useState(firstIndex === -1 ? 0 : firstIndex); - const [isDone, setIsDone] = useState(false); - - const pagination = usePagination({ - items: choices, - active: index, - renderItem: ({ item, isActive }) => { - if (item.isSeparator) { - return `${colorize.muted(symbols.separatorIndent + item.name)}`; - } - const pointer = isActive ? colorize.brand(symbols.pointer) : ' '; - const name = item.name ?? String(item.value); - const disabledTag = item.disabled ? colorize.muted(' (disabled)') : ''; - const styledName = isActive ? colorize.highlight(name) : name; - return `${pointer} ${styledName}${disabledTag}`; - }, - pageSize: 10, - loop: false, - }); - - useKeypress((key: any) => { - if (isDone) return; - - if (isUpKey(key)) { - let i = index - 1; - while (i >= 0 && (choices[i].disabled || choices[i].isSeparator)) i -= 1; - if (i >= 0) { - setIndex(i); - } - return; - } - if (isDownKey(key)) { - let i = index + 1; - while (i < choices.length && (choices[i].disabled || choices[i].isSeparator)) i += 1; - if (i < choices.length) { - setIndex(i); - } - return; - } - - if (key.name === 'left' || key.name === 'right') { - return; - } - - if (isEnterKey(key)) { - if (choices[index] && !choices[index].disabled && !choices[index].isSeparator) { - setIsDone(true); - done(choices[index].value); - } - return; - } - - if (key.name === 'escape' && config.allowBack) { - done(Symbol.for('back')); - return; - } - }); - - if (isDone) { - process.stdout.write('\x1B[?25h'); - const selected = choices[index]; - return `${colorize.brand(symbols.bullet)} ${config.message ?? ''}\n\n ${colorize.success(symbols.check)} ${colorize.brand(selected.name)}`; - } - - const msg = config.message ?? ''; - const current = choices[index]; - let currentDesc = ''; - - if (current?.description && !current.isSeparator) { - currentDesc += `${colorize.bold(current.description)}`; - if (current.caveat) { - currentDesc += `\n${colorize.warning(current.caveat)}`; - } - currentDesc += '\n\n'; - } else if (current?.caveat && !current.isSeparator) { - currentDesc += `${colorize.warning(current.caveat)}\n\n`; - } - - return `${colorize.brand(symbols.bullet)} ${colorize.brand(msg)}\n\n${currentDesc}${pagination} \n\n${ui.footer(config.footer?.back ?? false, config.footer?.exit ?? true)}`; -}); diff --git a/packages/core/src/ui/styling/colors.ts b/packages/core/src/ui/styling/colors.ts deleted file mode 100644 index 6edacc0..0000000 --- a/packages/core/src/ui/styling/colors.ts +++ /dev/null @@ -1,31 +0,0 @@ -export const colors = { - // Brand colors - salmon: [239, 149, 157] as const, // #EF959D- Primary brand color - pinkGlamour: [255, 118, 117] as const, // #FF7675 - Accent color - amber: [234, 179, 8] as const, // #eab308 - Warning/highlight color - - // Semantic colors - emerald: [46, 204, 113] as const, // #2ecc71 - Success color - alizarin: [231, 76, 60] as const, // #e74c3c - Error color - slate: [148, 163, 184] as const, // #94a3b8 - Muted/secondary color - - // Formatting - bold: '\x1b[1m', - muted: '\x1b[90m', - reset: '\x1b[0m', -} as const; - -// Utility functions for RGB to ANSI conversion -export const rgbToAnsi = (r: number, g: number, b: number) => `\x1b[38;2;${r};${g};${b}m`; - -// Semantic color functions -export const colorize = { - brand: (text: string) => `${rgbToAnsi(...colors.salmon)}${text}${colors.reset}`, - accent: (text: string) => `${rgbToAnsi(...colors.pinkGlamour)}${text}${colors.reset}`, - warning: (text: string) => `${rgbToAnsi(...colors.amber)}${text}${colors.reset}`, - success: (text: string) => `${rgbToAnsi(...colors.emerald)}${text}${colors.reset}`, - error: (text: string) => `${rgbToAnsi(...colors.alizarin)}${text}${colors.reset}`, - muted: (text: string) => `${rgbToAnsi(...colors.slate)}${text}${colors.reset}`, - bold: (text: string) => `${colors.bold}${text}${colors.reset}`, - highlight: (text: string) => `${colors.bold}${rgbToAnsi(...colors.salmon)}${text}${colors.reset}`, -} as const; \ No newline at end of file diff --git a/packages/core/src/ui/styling/confirm.ts b/packages/core/src/ui/styling/confirm.ts deleted file mode 100644 index f846c7a..0000000 --- a/packages/core/src/ui/styling/confirm.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ConfirmTheme } from "@/types"; -import { symbols } from "@/ui/styling/symbols"; -import { colorize } from "@/ui/styling/colors"; - -export const confirmTheme: ConfirmTheme = { - prefix: { - idle: colorize.brand(symbols.bullet), - done: colorize.success(symbols.check), - }, - spinner: { - interval: symbols.spinner.interval, - frames: Array.from(symbols.spinner.frames), - }, - style: { - answer: (text: string) => colorize.brand(text), - message: (text: string, status: 'idle' | 'done' | 'loading') => { - switch (status) { - case 'done': - return colorize.success(text); - case 'loading': - return colorize.warning(text); - default: - return colorize.brand(text); - } - }, - defaultAnswer: (text: string) => colorize.muted(text), - }, -}; diff --git a/packages/core/src/ui/styling/index.ts b/packages/core/src/ui/styling/index.ts deleted file mode 100644 index 47246a1..0000000 --- a/packages/core/src/ui/styling/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { colors, colorize, rgbToAnsi } from './colors'; -export { symbols } from './symbols'; -export { confirmTheme } from './confirm'; -export { ui } from './ui'; diff --git a/packages/core/src/ui/styling/symbols.ts b/packages/core/src/ui/styling/symbols.ts deleted file mode 100644 index af5e747..0000000 --- a/packages/core/src/ui/styling/symbols.ts +++ /dev/null @@ -1,24 +0,0 @@ -export const symbols = { - bullet: '•', - pointer: '➤', - check: '✓', - checkbox: { - checked: '[x]', - unchecked: '[ ]', - disabled: ' - ', - }, - separatorIndent: ' ', - spinner: { - interval: 80, - frames: ['⠋','⠙','⠸','⠴','⠦','⠇'], - }, - // Additional styling symbols - arrow: '→', - star: '★', - diamond: '◆', - circle: '●', - square: '■', - triangle: '▲', -} as const; - - diff --git a/packages/core/src/ui/styling/ui.ts b/packages/core/src/ui/styling/ui.ts deleted file mode 100644 index 7593adf..0000000 --- a/packages/core/src/ui/styling/ui.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { colorize } from './colors'; -import { symbols } from './symbols'; - -// Common UI patterns and layouts -export const ui = { - // Screen utilities - clearScreen: () => { - // Clear screen and scrollback buffer - process.stdout.write('\x1B[2J\x1B[3J\x1B[H'); - }, - - // Header and footer - header: () => { - const title = `${colorize.brand('✻')} ${colorize.highlight('Devcontainer Wizard by The Red Guild 🪷')}`; - return `${title}`; - }, - - footer: (back: boolean = false, exit: boolean = true) => { - const commands = []; - if (back) commands.push(`ESC ${colorize.muted('Back')}`); - if (exit) commands.push(`CTRL+C ${colorize.muted('Exit')}`); - - if (commands.length === 0) return ''; - - const commandText = commands.join(' '); - return `\n` + - `${colorize.muted('Shortcuts:')} ${commandText}`; - }, - - // Section headers - sectionHeader: (title: string) => `${colorize.brand(symbols.diamond)} ${colorize.highlight(title)}`, - - // Status indicators - status: { - success: (text: string) => `${colorize.success(symbols.check)} ${text}`, - error: (text: string) => `${colorize.error(symbols.circle)} ${text}`, - warning: (text: string) => `${colorize.warning(symbols.triangle)} ${text}`, - info: (text: string) => `${colorize.brand(symbols.bullet)} ${text}`, - }, - - // Progress indicators - progress: { - step: (current: number, total: number, label: string) => - `${colorize.muted(`[${current}/${total}]`)} ${colorize.brand(label)}`, - complete: (label: string) => `${colorize.success(symbols.check)} ${colorize.success(label)}`, - }, - - // Lists and items - list: { - item: (text: string, active: boolean = false) => - active ? colorize.highlight(` ${symbols.pointer} ${text}`) : ` ${text}`, - selected: (text: string) => `${colorize.success(symbols.check)} ${colorize.brand(text)}`, - disabled: (text: string) => `${colorize.muted(text)} (disabled)`, - }, - - // Separators and dividers - separator: (text?: string) => - text ? colorize.muted(`── ${text} ──`) : colorize.muted('─'.repeat(40)), - - // Input styling - input: { - label: (text: string) => colorize.brand(text), - value: (text: string) => colorize.brand(text), - placeholder: (text: string) => colorize.muted(text), - error: (text: string) => colorize.error(text), - }, - - // Navigation - navigation: { - back: () => colorize.muted(`← Back`), - next: () => colorize.brand(`Next →`), - cancel: () => colorize.error(`Cancel`), - confirm: () => colorize.success(`Confirm`), - }, -} as const; diff --git a/packages/core/src/util/slug.ts b/packages/core/src/util/slug.ts new file mode 100644 index 0000000..5a29fcc --- /dev/null +++ b/packages/core/src/util/slug.ts @@ -0,0 +1,31 @@ +/** Slugify a project/environment name into a safe identifier (matches the original wizard). */ +export function slugify(name: string): string { + return ( + name + .toLowerCase() + .replace(/[^a-z0-9._-]+/gi, '-') + .replace(/^-+|-+$/g, '') || 'default' + ) +} + +/** + * Returns true if `name` is a safe, canonical environment identifier — i.e. it + * survives `slugify` unchanged and is not a degenerate (leading-dot / all-dots) + * value that would traverse paths or create hidden manifest files. + * + * This is the single source of truth used both at create time (after slugify) + * and at every lifecycle command that resolves a name from user input or a + * `.dcw` marker file. + */ +/** Longest accepted environment name — keeps `.json`, `dcw-` container + * names and `dcw/:latest` image tags well under filesystem/engine limits. */ +export const MAX_ENV_NAME_LENGTH = 63 + +export function isValidEnvName(name: string): boolean { + if (name.length > MAX_ENV_NAME_LENGTH) return false + if (name !== slugify(name)) return false + // Reject leading-dot names ('.', '...', '.hidden') that would create hidden + // manifest files or escape the environments dir. + if (name.startsWith('.')) return false + return true +} diff --git a/packages/core/src/util/which.ts b/packages/core/src/util/which.ts new file mode 100644 index 0000000..260032f --- /dev/null +++ b/packages/core/src/util/which.ts @@ -0,0 +1,16 @@ +import { capture } from '../engine/exec.js' + +/** + * True when `bin` is resolvable on PATH. + * + * Tries `command -v` first, then `which`. `command` is a POSIX shell builtin: macOS + * ships a `/usr/bin/command` binary, but most Linux distros do not, so spawning it + * fails there. Without the `which` fallback, callers silently take their "not on + * PATH" branch on Linux even when the binary is present. + */ +export async function isOnPath(bin: string): Promise { + const viaCommand = await capture('command', ['-v', bin]) + if (!viaCommand.spawnError && viaCommand.code === 0 && viaCommand.stdout.trim()) return true + const viaWhich = await capture('which', [bin]) + return viaWhich.code === 0 && viaWhich.stdout.trim().length > 0 +} diff --git a/packages/core/src/utils/openIn.ts b/packages/core/src/utils/openIn.ts deleted file mode 100644 index 8046f28..0000000 --- a/packages/core/src/utils/openIn.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { selectWithTopDescription } from '@/ui/components/selectWithTopDescription' -import { execSync } from 'node:child_process' - -function isCursorAvailable(): boolean { - try { - execSync('which cursor', { stdio: 'ignore' }) - return true - } catch { - return false - } -} - -export async function openIn() { - const choices = [ - { name: 'Terminal', value: 'shell' }, - { name: 'VS Code', value: 'code' }, - ] - - if (isCursorAvailable()) { - choices.push({ name: 'Cursor', value: 'cursor' }) - } - - return await selectWithTopDescription({ - message: 'Select an interface to attach to the devcontainer:', - choices, - }) -} \ No newline at end of file diff --git a/packages/core/src/utils/shouldRun.ts b/packages/core/src/utils/shouldRun.ts deleted file mode 100644 index fa5b455..0000000 --- a/packages/core/src/utils/shouldRun.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { confirmWithFooter as confirm } from "@/ui/components"; -import { colorize } from "@/ui/styling/colors"; -import { symbols } from "@/ui/styling/symbols"; -import { ui } from "@/ui/styling/ui"; -import { openIn } from "@/utils/openIn"; -import { devcontainerUp, devcontainerExec } from "@/core/devcontainer"; - -export async function shouldRun(devcontainerPath: string) { - try { - const shouldRun = await confirm({ - message: colorize.brand(symbols.diamond + ' Would you like to start the devcontainer now?'), - default: true, - footer: { - back: true, - exit: true, - }, - allowBack: true - }); - - if ((shouldRun as any) === Symbol.for('back')) { - return Symbol.for('back') as any; - } - - const containerId = await devcontainerUp(devcontainerPath); - - if (shouldRun) { - ui.clearScreen() - const openInSelection = await openIn(); - await devcontainerExec(containerId, openInSelection); - } else { - console.log(colorize.brand(symbols.diamond + ' You can start it later with:')); - console.log("npx @devcontainers/cli exec --container-id " + containerId + " bash"); - } - } catch (error) { - if (error instanceof Error && (error.message === 'User force closed the prompt with SIGINT' || error.message === 'User force closed the prompt with SIGTERM')) { - console.log('\nExited with CTRL+C 👋') - process.exit(0) - } - throw error; - } -} diff --git a/packages/core/src/utils/versionCheck.ts b/packages/core/src/utils/versionCheck.ts deleted file mode 100644 index 9d2a55f..0000000 --- a/packages/core/src/utils/versionCheck.ts +++ /dev/null @@ -1,266 +0,0 @@ -import * as fs from 'fs' -import * as path from 'path' -import * as os from 'os' -import * as semver from 'semver' - -interface UpdateCheckCache { - checkedAt: number - latestVersion: string - hasUpdate: boolean -} - -interface UpdateCheckResult { - hasUpdate: boolean - currentVersion: string - latestVersion: string - updateCommand: string -} - -const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours -const GITHUB_API_URL = 'https://api.github.com/repos/theredguild/devcontainer-wizard/tags' -const GITHUB_API_TIMEOUT = 3000 // 3 seconds timeout - -/** - * Get the cache directory path based on the operating system - */ -function getCacheDir(): string { - const platform = os.platform() - let cacheDir: string - - if (platform === 'win32') { - cacheDir = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'devcontainer-wizard') - } else if (platform === 'darwin') { - cacheDir = path.join(os.homedir(), 'Library', 'Caches', 'devcontainer-wizard') - } else { - // Linux and other Unix-like systems - cacheDir = path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), 'devcontainer-wizard') - } - - return cacheDir -} - -/** - * Get the cache file path - */ -function getCacheFilePath(): string { - return path.join(getCacheDir(), 'update-check.json') -} - -/** - * Read cache from disk - */ -function readCache(): UpdateCheckCache | null { - try { - const cacheFilePath = getCacheFilePath() - if (!fs.existsSync(cacheFilePath)) { - return null - } - - const cacheData = fs.readFileSync(cacheFilePath, 'utf-8') - const cache: UpdateCheckCache = JSON.parse(cacheData) - - // Check if cache is still valid - const now = Date.now() - if (now - cache.checkedAt < CACHE_TTL_MS) { - return cache - } - - return null - } catch { - // Fail silently if cache read fails - return null - } -} - -/** - * Write cache to disk - */ -function writeCache(cache: UpdateCheckCache): void { - try { - const cacheDir = getCacheDir() - const cacheFilePath = getCacheFilePath() - - // Create cache directory if it doesn't exist - if (!fs.existsSync(cacheDir)) { - fs.mkdirSync(cacheDir, { recursive: true }) - } - - fs.writeFileSync(cacheFilePath, JSON.stringify(cache, null, 2), 'utf-8') - } catch { - // Fail silently if cache write fails - } -} - -/** - * Fetch latest tag from GitHub API - */ -async function fetchLatestTag(): Promise { - try { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), GITHUB_API_TIMEOUT) - - const response = await fetch(GITHUB_API_URL, { - signal: controller.signal, - headers: { - 'Accept': 'application/vnd.github.v3+json', - 'User-Agent': 'devcontainer-wizard' - } - }) - - clearTimeout(timeoutId) - - if (!response.ok) { - return null - } - - const tags = await response.json() as Array<{ name: string }> - - if (!tags || tags.length === 0) { - return null - } - - // Get the first tag (latest) - const latestTag = tags[0].name - - // Remove 'v' prefix if present - return latestTag.startsWith('v') ? latestTag.slice(1) : latestTag - } catch { - // Fail silently on network errors, timeouts, etc. - return null - } -} - -/** - * Get current package version from package.json - */ -function getCurrentVersion(): string { - try { - // Read package.json from the package root - // Use require.resolve to find the package.json - const packageJsonPath = require.resolve('../../package.json') - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) - return packageJson.version - } catch { - // Fallback to a default version if reading fails - return '0.0.0' - } -} - -/** - * Detect which package manager was used to install the CLI - */ -function detectPackageManager(): 'npm' | 'pnpm' | 'yarn' { - // Check npm_config_user_agent environment variable - const userAgent = process.env.npm_config_user_agent || '' - - if (userAgent.includes('pnpm')) { - return 'pnpm' - } - - if (userAgent.includes('yarn')) { - return 'yarn' - } - - // Default to npm - return 'npm' -} - -/** - * Get the appropriate update command based on package manager - */ -function getUpdateCommand(): string { - const packageManager = detectPackageManager() - - switch (packageManager) { - case 'pnpm': - return 'pnpm add -g devcontainer-wizard@latest' - case 'yarn': - return 'yarn global add devcontainer-wizard@latest' - case 'npm': - default: - return 'npm install -g devcontainer-wizard@latest' - } -} - -/** - * Check if update check is disabled via environment variable - */ -function isUpdateCheckDisabled(): boolean { - const envVar = process.env.DEVCONTAINER_WIZARD_DISABLE_UPDATE_CHECK - return envVar === '1' || envVar === 'true' || envVar === 'yes' -} - -/** - * Check for updates and return result - * This function is non-blocking and fails silently on errors - */ -export async function checkForUpdates(): Promise { - // Check if update check is disabled - if (isUpdateCheckDisabled()) { - return null - } - - const currentVersion = getCurrentVersion() - - // Try to read from cache first - const cachedResult = readCache() - if (cachedResult) { - // Re-check hasUpdate against current version in case it changed - let hasUpdate = false - try { - const current = semver.clean(currentVersion) - const latest = semver.clean(cachedResult.latestVersion) - - if (current && latest) { - hasUpdate = semver.gt(latest, current) - } - } catch { - hasUpdate = false - } - - return { - hasUpdate, - currentVersion, - latestVersion: cachedResult.latestVersion, - updateCommand: getUpdateCommand() - } - } - - // Fetch latest version from GitHub - const latestVersion = await fetchLatestTag() - - if (!latestVersion) { - // If fetch fails, return null (fail silently) - return null - } - - // Compare versions using semver - let hasUpdate = false - try { - const current = semver.clean(currentVersion) - const latest = semver.clean(latestVersion) - - if (current && latest) { - hasUpdate = semver.gt(latest, current) - } - } catch { - // If semver comparison fails, assume no update - hasUpdate = false - } - - // Write to cache - const cache: UpdateCheckCache = { - checkedAt: Date.now(), - latestVersion, - hasUpdate - } - writeCache(cache) - - return { - hasUpdate, - currentVersion, - latestVersion, - updateCommand: getUpdateCommand() - } -} - diff --git a/packages/core/src/wizard/App.tsx b/packages/core/src/wizard/App.tsx new file mode 100644 index 0000000..0f7a3f9 --- /dev/null +++ b/packages/core/src/wizard/App.tsx @@ -0,0 +1,311 @@ +import { Box, Text, useApp, useInput } from 'ink' +import { useState } from 'react' +import { + AI_AGENTS, + CORE_LANGUAGES, + FRAMEWORKS, + FUZZING_AND_TESTING, + LANGUAGES, + SECURITY_TOOLING, + type CatalogItem, +} from '../domain/catalog.js' +import { HARDENING_OPTIONS } from '../domain/hardening.js' +import { DEFAULT_PROFILE, NO_PROFILE, PROFILES, recipesToHardening } from '../domain/profiles.js' +import { createDriver } from '../engine/registry.js' +import type { EngineStatus } from '../engine/resolver.js' +import type { EngineName } from '../engine/types.js' +import { hardeningToEffects } from '../hardening/effects.js' +import { translate } from '../hardening/translator.js' +import type { EnvSpec } from '../spec/env-spec.js' +import { flagsToSpec, type FlagInput } from '../spec/flags-to-spec.js' +import { Banner } from './components/Banner.js' +import { MultiSelect } from './components/MultiSelect.js' +import { Select } from './components/Select.js' +import { TextInput } from './components/TextInput.js' +import { useStepNav } from './hooks/useStepNav.js' + +interface Draft { + engine: string + name: string + coreLanguages: string[] + languages: string[] + frameworks: string[] + fuzzingAndTesting: string[] + securityTooling: string[] + aiAgents: string[] + profile?: string + hardening: string[] +} + +export interface AppProps { + initial: FlagInput + engines: EngineStatus[] + onComplete: (spec: EnvSpec) => void + onCancel: () => void +} + +const STEP_TITLES = [ + 'Container engine', + 'Name', + 'Core languages', + 'Smart-contract languages', + 'Frameworks', + 'Fuzzing & testing', + 'Security tooling', + 'AI coding agents', + 'Hardening', + 'Review', +] + +function toItems(items: CatalogItem[]) { + return items.map((i) => ({ label: i.label, value: i.value, hint: i.description })) +} + +function draftToFlags(draft: Draft, initial: FlagInput): FlagInput { + return { + name: draft.name, + coreLanguages: draft.coreLanguages, + languages: draft.languages, + frameworks: draft.frameworks, + fuzzingAndTesting: draft.fuzzingAndTesting, + securityTooling: draft.securityTooling, + aiAgents: draft.aiAgents, + profile: draft.profile, + hardening: draft.hardening, + engine: draft.engine, + gitUrl: initial.gitUrl, + gitBranch: initial.gitBranch, + fallbackName: initial.fallbackName, + } +} + +export function App({ initial, engines, onComplete, onCancel }: AppProps) { + const { index, next, back } = useStepNav(STEP_TITLES.length) + const recommended = engines.find((e) => e.recommended)?.name + const [draft, setDraft] = useState({ + engine: initial.engine ?? 'auto', + name: initial.name ?? initial.fallbackName ?? '', + coreLanguages: initial.coreLanguages ?? [], + languages: initial.languages ?? [], + frameworks: initial.frameworks ?? [], + fuzzingAndTesting: initial.fuzzingAndTesting ?? [], + securityTooling: initial.securityTooling ?? [], + aiAgents: initial.aiAgents ?? [], + profile: initial.profile, + hardening: initial.hardening ?? [], + }) + + const patch = (p: Partial) => setDraft((d) => ({ ...d, ...p })) + + // Ctrl-C cancels (ink's default exit is also wired in run.tsx). + useInput((input, key) => { + if (key.ctrl && input === 'c') onCancel() + }) + + function renderStep() { + switch (index) { + case 0: { + const choices = [ + { + label: 'Auto-detect', + value: 'auto', + hint: recommended ? `Recommended: ${recommended}` : 'No engine detected yet', + }, + ...engines.map((e) => ({ + label: `${e.displayName}${e.recommended ? ' ★' : ''}`, + value: e.name, + disabled: !e.platform.supported, + hint: !e.platform.supported + ? e.platform.reason + : e.detect?.available + ? `available${e.detect.version ? ` — ${e.detect.version}` : ''}${describeDrops(e.name)}` + : `not detected${describeDrops(e.name)}`, + })), + ] + return { + if (v === '__custom__') setMode('custom') + else if (v === NO_PROFILE) onSubmit(NO_PROFILE, []) + else onSubmit(v, recipesToHardening([v])) + }} + onBack={onBack} + /> + ) +} + +function ReviewStep({ + draft, + initial, + recommended, + onConfirm, + onBack, +}: { + draft: Draft + initial: FlagInput + recommended?: EngineName + onConfirm: (spec: EnvSpec) => void + onBack: () => void +}) { + let spec: EnvSpec | null = null + let error: string | null = null + try { + spec = flagsToSpec(draftToFlags(draft, initial)) + } catch (e) { + error = (e as Error).message + } + + const engineForCaps: EngineName | undefined = + draft.engine !== 'auto' ? (draft.engine as EngineName) : recommended + const translation = + spec && engineForCaps + ? translate(hardeningToEffects(spec.hardening as never), createDriver(engineForCaps).capabilities, engineForCaps) + : null + + useInput((_input, key) => { + if (key.return && spec) onConfirm(spec) + else if (key.escape) onBack() + }) + + if (error || !spec) { + return ( + + Cannot build spec: {error} + esc to go back + + ) + } + + const sel = spec.selections + const line = (k: string, v?: string[]) => (v && v.length ? `${k}: ${v.join(', ')}` : null) + const lines = [ + `name: ${spec.name}`, + `engine: ${spec.engine}${spec.engine === 'auto' && recommended ? ` (→ ${recommended})` : ''}`, + line('core', sel.coreLanguages), + line('languages', sel.languages), + line('frameworks', sel.frameworks), + line('fuzzing', sel.fuzzingAndTesting), + line('security', sel.securityTooling), + line('ai agents', sel.aiAgents), + spec.hardening.length ? `hardening: ${spec.hardening.join(', ')}` : 'hardening: none', + ].filter(Boolean) as string[] + + return ( + + {lines.map((l) => ( + {l} + ))} + {translation && translation.dropped.length > 0 ? ( + + + ⚠ {engineForCaps} cannot honor: {translation.dropped.map((e) => e.kind).join(', ')} + + + ) : null} + + enter to create · esc to go back + + + ) +} diff --git a/packages/core/src/wizard/components/Banner.tsx b/packages/core/src/wizard/components/Banner.tsx new file mode 100644 index 0000000..c44a0fb --- /dev/null +++ b/packages/core/src/wizard/components/Banner.tsx @@ -0,0 +1,14 @@ +import { Box, Text } from 'ink' + +export function Banner({ step, total, title }: { step: number; total: number; title: string }) { + return ( + + + dcw · container environment wizard + + + Step {step + 1}/{total} — {title} + + + ) +} diff --git a/packages/core/src/wizard/components/MultiSelect.tsx b/packages/core/src/wizard/components/MultiSelect.tsx new file mode 100644 index 0000000..a78e559 --- /dev/null +++ b/packages/core/src/wizard/components/MultiSelect.tsx @@ -0,0 +1,64 @@ +import { Box, Text, useInput } from 'ink' +import { useState } from 'react' + +export interface MultiSelectItem { + label: string + value: string + hint?: string +} + +export interface MultiSelectProps { + items: MultiSelectItem[] + initial?: string[] + onSubmit: (values: string[]) => void + onBack?: () => void +} + +/** Multi-choice list. Arrows move, Space toggles, Enter confirms, Esc goes back. */ +export function MultiSelect({ items, initial = [], onSubmit, onBack }: MultiSelectProps) { + const [cursor, setCursor] = useState(0) + const [selected, setSelected] = useState>(new Set(initial)) + + useInput((input, key) => { + if (key.upArrow) setCursor((c) => (c - 1 + items.length) % items.length) + else if (key.downArrow) setCursor((c) => (c + 1) % items.length) + else if (input === ' ') { + const item = items[cursor] + if (item) { + setSelected((prev) => { + const nextSet = new Set(prev) + if (nextSet.has(item.value)) nextSet.delete(item.value) + else nextSet.add(item.value) + return nextSet + }) + } + } else if (key.return) { + onSubmit(items.filter((i) => selected.has(i.value)).map((i) => i.value)) + } else if (key.escape) onBack?.() + }) + + return ( + + {items.length === 0 ? (no options) : null} + {items.map((item, i) => { + const active = i === cursor + const checked = selected.has(item.value) + return ( + + {active ? '❯ ' : ' '} + {checked ? '◉ ' : '◯ '} + {item.label} + + ) + })} + {items[cursor]?.hint ? ( + + {items[cursor]!.hint} + + ) : null} + + space toggle · enter confirm · esc back + + + ) +} diff --git a/packages/core/src/wizard/components/Select.tsx b/packages/core/src/wizard/components/Select.tsx new file mode 100644 index 0000000..7b3d019 --- /dev/null +++ b/packages/core/src/wizard/components/Select.tsx @@ -0,0 +1,66 @@ +import { Box, Text, useInput } from 'ink' +import { useState } from 'react' + +export interface SelectChoice { + label: string + value: string + hint?: string + disabled?: boolean +} + +export interface SelectProps { + choices: SelectChoice[] + initialValue?: string + onSubmit: (value: string) => void + onBack?: () => void +} + +function nextEnabled(choices: SelectChoice[], from: number, dir: 1 | -1): number { + const n = choices.length + let i = from + for (let step = 0; step < n; step++) { + i = (i + dir + n) % n + if (!choices[i]?.disabled) return i + } + return from +} + +/** Single-choice list. Arrows move, Enter selects, Esc goes back. */ +export function Select({ choices, initialValue, onSubmit, onBack }: SelectProps) { + const initialIndex = Math.max( + 0, + choices.findIndex((c) => c.value === initialValue && !c.disabled), + ) + const start = choices[initialIndex]?.disabled ? nextEnabled(choices, initialIndex, 1) : initialIndex + const [cursor, setCursor] = useState(start) + + useInput((_input, key) => { + if (key.upArrow) setCursor((c) => nextEnabled(choices, c, -1)) + else if (key.downArrow) setCursor((c) => nextEnabled(choices, c, 1)) + else if (key.return) { + const choice = choices[cursor] + if (choice && !choice.disabled) onSubmit(choice.value) + } else if (key.escape) onBack?.() + }) + + return ( + + {choices.map((c, i) => { + const active = i === cursor + const color = c.disabled ? 'gray' : active ? 'cyan' : undefined + return ( + + {active ? '❯ ' : ' '} + {c.label} + {c.disabled ? ' (unavailable)' : ''} + + ) + })} + {choices[cursor]?.hint ? ( + + {choices[cursor]!.hint} + + ) : null} + + ) +} diff --git a/packages/core/src/wizard/components/TextInput.tsx b/packages/core/src/wizard/components/TextInput.tsx new file mode 100644 index 0000000..66e520d --- /dev/null +++ b/packages/core/src/wizard/components/TextInput.tsx @@ -0,0 +1,40 @@ +import { Box, Text, useInput } from 'ink' +import { useState } from 'react' + +export interface TextInputProps { + label: string + initial?: string + onSubmit: (value: string) => void + onBack?: () => void +} + +/** Minimal single-line text input. Enter submits, Esc goes back. */ +export function TextInput({ label, initial = '', onSubmit, onBack }: TextInputProps) { + const [value, setValue] = useState(initial) + + useInput((input, key) => { + if (key.return) { + onSubmit(value.trim()) + return + } + if (key.escape) { + onBack?.() + return + } + if (key.backspace || key.delete) { + setValue((v) => v.slice(0, -1)) + return + } + if (input && !key.ctrl && !key.meta) { + setValue((v) => v + input) + } + }) + + return ( + + {label} + {value} + + + ) +} diff --git a/packages/core/src/wizard/hooks/useStepNav.ts b/packages/core/src/wizard/hooks/useStepNav.ts new file mode 100644 index 0000000..578eed8 --- /dev/null +++ b/packages/core/src/wizard/hooks/useStepNav.ts @@ -0,0 +1,19 @@ +import { useState } from 'react' + +export interface StepNav { + index: number + next: () => void + back: () => void + atStart: boolean +} + +/** Linear forward/back navigation across a fixed number of wizard steps. */ +export function useStepNav(total: number): StepNav { + const [index, setIndex] = useState(0) + return { + index, + next: () => setIndex((i) => Math.min(total - 1, i + 1)), + back: () => setIndex((i) => Math.max(0, i - 1)), + atStart: index === 0, + } +} diff --git a/packages/core/src/wizard/run.tsx b/packages/core/src/wizard/run.tsx new file mode 100644 index 0000000..209915b --- /dev/null +++ b/packages/core/src/wizard/run.tsx @@ -0,0 +1,41 @@ +import { render } from 'ink' +import { createElement } from 'react' +import { detectHost } from '../engine/host.js' +import { surveyEngines } from '../engine/resolver.js' +import type { EnvSpec } from '../spec/env-spec.js' +import type { FlagInput } from '../spec/flags-to-spec.js' +import { App } from './App.js' + +export interface MountWizardOptions { + initial: FlagInput +} + +/** + * Render the ink wizard and resolve with the authored EnvSpec, or null if the + * user cancels. Loaded via dynamic import so React/ink never touch the + * non-interactive (JSON / no-TTY) code path. + */ +export async function mountWizard(opts: MountWizardOptions): Promise { + const host = await detectHost() + const engines = await surveyEngines({ host }) + + return new Promise((resolve) => { + let settled = false + const finish = (value: EnvSpec | null) => { + if (settled) return + settled = true + instance.unmount() + resolve(value) + } + + const instance = render( + createElement(App, { + initial: opts.initial, + engines, + onComplete: (spec) => finish(spec), + onCancel: () => finish(null), + }), + { exitOnCtrlC: false }, + ) + }) +} diff --git a/packages/core/test/e2e/smoke.test.ts b/packages/core/test/e2e/smoke.test.ts new file mode 100644 index 0000000..3636c1b --- /dev/null +++ b/packages/core/test/e2e/smoke.test.ts @@ -0,0 +1,48 @@ +import { execFile } from 'node:child_process' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +const run = promisify(execFile) + +// Gated: requires DCW_E2E=1, a built dist, and a working container engine. +const ENABLED = process.env.DCW_E2E === '1' +const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') +const bin = path.join(pkgRoot, 'bin', 'run.js') + +describe.skipIf(!ENABLED)('e2e smoke (create → build → up → exec → stop → rm)', () => { + let env: NodeJS.ProcessEnv + let configDir: string + let stateDir: string + + beforeAll(async () => { + configDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dcw-e2e-cfg-')) + stateDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dcw-e2e-state-')) + env = { ...process.env, XDG_CONFIG_HOME: configDir, XDG_STATE_HOME: stateDir } + }) + + afterAll(async () => { + await run('node', [bin, 'rm', 'e2e', '--purge', '--yes'], { env }).catch(() => undefined) + await fs.rm(configDir, { recursive: true, force: true }) + await fs.rm(stateDir, { recursive: true, force: true }) + }) + + it('runs the full lifecycle and lands a vscode shell in /workspace', async () => { + await run('node', [bin, 'create', '--no-input', '--name', 'e2e', '--harden', 'drop-caps', '--build', '--up', '--json'], { + env, + timeout: 540_000, + }) + + const { stdout } = await run('node', [bin, 'exec', 'e2e', '--', 'sh', '-lc', 'echo "$(whoami):$(pwd)"'], { env }) + expect(stdout).toContain('vscode:/workspace') + + const ls = await run('node', [bin, 'ls', '--json'], { env }) + const parsed = JSON.parse(ls.stdout) as { environments: Array<{ name: string; status: string }> } + expect(parsed.environments.find((e) => e.name === 'e2e')?.status).toBe('running') + + await run('node', [bin, 'stop', 'e2e'], { env }) + }, 600_000) +}) diff --git a/packages/core/test/engine/fake-driver.ts b/packages/core/test/engine/fake-driver.ts new file mode 100644 index 0000000..5132d5f --- /dev/null +++ b/packages/core/test/engine/fake-driver.ts @@ -0,0 +1,92 @@ +import { caps } from '../../src/engine/drivers/capabilities.js' +import type { CaptureResult } from '../../src/engine/exec.js' +import type { + BuildSpec, + ContainerInfo, + DetectResult, + EngineCapabilities, + EngineDriver, + EngineName, + ExecSpec, + PsFilter, + RunSpec, +} from '../../src/engine/types.js' + +export interface FakeDriverOptions { + name: EngineName + displayName?: string + detect?: DetectResult + capabilities?: EngineCapabilities + /** Result the report probe (`runOnce`) returns; defaults to a clean read. */ + runOnceResult?: { stdout: string; code: number } +} + +/** In-memory EngineDriver that records the specs it receives — no real daemon. */ +export class FakeDriver implements EngineDriver { + readonly name: EngineName + readonly displayName: string + readonly capabilities: EngineCapabilities + private readonly detectResult: DetectResult + private readonly runOnceResult: { stdout: string; code: number } + + builds: BuildSpec[] = [] + runs: RunSpec[] = [] + execs: ExecSpec[] = [] + stops: string[] = [] + rms: Array<{ id: string; force?: boolean }> = [] + + constructor(opts: FakeDriverOptions) { + this.name = opts.name + this.displayName = opts.displayName ?? opts.name + this.capabilities = opts.capabilities ?? caps() + this.detectResult = opts.detect ?? { available: true, version: 'fake-1.0' } + this.runOnceResult = opts.runOnceResult ?? { stdout: '', code: 0 } + } + + async detect(): Promise { + return this.detectResult + } + + async build(spec: BuildSpec): Promise<{ imageId: string }> { + this.builds.push(spec) + return { imageId: `sha256:fake-${spec.tag}` } + } + + async run(spec: RunSpec): Promise<{ containerId: string }> { + this.runs.push(spec) + return { containerId: `cid-${spec.name}` } + } + + async exec(spec: ExecSpec): Promise { + this.execs.push(spec) + return 0 + } + + async execCapture(spec: ExecSpec): Promise { + this.execs.push(spec) + return { code: 0, stdout: '', stderr: '', spawnError: false } + } + + async stop(id: string): Promise { + this.stops.push(id) + } + + async rm(id: string, opts: { force?: boolean } = {}): Promise { + this.rms.push({ id, force: opts.force }) + } + + async ps(_filter?: PsFilter): Promise { + return [] + } + + async logs(): Promise { + return 0 + } + + runOnces: Array<{ image: string; cmd: string[]; flags: string[] }> = [] + + async runOnce(image: string, cmd: string[], flags: string[] = []): Promise<{ stdout: string; code: number }> { + this.runOnces.push({ image, cmd, flags }) + return this.runOnceResult + } +} diff --git a/packages/core/test/engine/host.test.ts b/packages/core/test/engine/host.test.ts new file mode 100644 index 0000000..7fdfd3d --- /dev/null +++ b/packages/core/test/engine/host.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { enginePlatformSupport, enginePreference, type HostInfo } from '../../src/engine/host.js' + +const macSilicon: HostInfo = { os: 'macos', arch: 'arm64', macosMajor: 15 } +const macIntel: HostInfo = { os: 'macos', arch: 'x64', macosMajor: 14 } +const macOld: HostInfo = { os: 'macos', arch: 'arm64', macosMajor: 14 } +const linux: HostInfo = { os: 'linux', arch: 'x64' } +const windows: HostInfo = { os: 'windows', arch: 'x64' } + +describe('enginePlatformSupport', () => { + it('supports docker/podman everywhere', () => { + for (const host of [macSilicon, linux, windows]) { + expect(enginePlatformSupport('docker', host).supported).toBe(true) + expect(enginePlatformSupport('podman', host).supported).toBe(true) + } + }) + + it('limits orbstack to macOS', () => { + expect(enginePlatformSupport('orbstack', macSilicon).supported).toBe(true) + expect(enginePlatformSupport('orbstack', linux).supported).toBe(false) + }) + + it('limits lima to macOS and linux', () => { + expect(enginePlatformSupport('lima', macSilicon).supported).toBe(true) + expect(enginePlatformSupport('lima', linux).supported).toBe(true) + expect(enginePlatformSupport('lima', windows).supported).toBe(false) + }) + + it('limits apple-container to macOS 15+ on Apple Silicon', () => { + expect(enginePlatformSupport('apple-container', macSilicon).supported).toBe(true) + expect(enginePlatformSupport('apple-container', linux).supported).toBe(false) + expect(enginePlatformSupport('apple-container', macIntel).supported).toBe(false) + expect(enginePlatformSupport('apple-container', macOld).supported).toBe(false) + }) +}) + +describe('enginePreference', () => { + it('prefers orbstack first on macOS', () => { + expect(enginePreference(macSilicon)[0]).toBe('orbstack') + expect(enginePreference(macSilicon)).toContain('apple-container') + }) + + it('excludes mac-only engines on linux', () => { + const pref = enginePreference(linux) + expect(pref).not.toContain('orbstack') + expect(pref).not.toContain('apple-container') + expect(pref[0]).toBe('docker') + }) +}) diff --git a/packages/core/test/engine/resolver.test.ts b/packages/core/test/engine/resolver.test.ts new file mode 100644 index 0000000..c8c62a9 --- /dev/null +++ b/packages/core/test/engine/resolver.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { EngineUnavailableError, EngineUnsupportedError, NoEngineError } from '../../src/errors.js' +import type { HostInfo } from '../../src/engine/host.js' +import { createAllDrivers } from '../../src/engine/registry.js' +import { resolveEngine, surveyEngines } from '../../src/engine/resolver.js' +import type { EngineDriver, EngineName } from '../../src/engine/types.js' +import { FakeDriver } from './fake-driver.js' + +const macSilicon: HostInfo = { os: 'macos', arch: 'arm64', macosMajor: 15 } +const linux: HostInfo = { os: 'linux', arch: 'x64' } + +function drivers(spec: Partial>): EngineDriver[] { + const all: EngineName[] = ['docker', 'podman', 'orbstack', 'apple-container', 'lima'] + return all.map( + (name) => + new FakeDriver({ + name, + detect: { available: spec[name] ?? false, reason: spec[name] ? undefined : 'not detected' }, + }), + ) +} + +describe('resolveEngine (auto)', () => { + it('picks orbstack first on macOS when available', async () => { + const res = await resolveEngine({ host: macSilicon, drivers: drivers({ orbstack: true, docker: true }) }) + expect(res.driver.name).toBe('orbstack') + }) + + it('falls through to docker when orbstack is unavailable', async () => { + const res = await resolveEngine({ host: macSilicon, drivers: drivers({ orbstack: false, docker: true }) }) + expect(res.driver.name).toBe('docker') + }) + + it('never auto-selects a platform-unsupported engine', async () => { + // apple-container "available" but on linux it is platform-unsupported and must be skipped. + const res = await resolveEngine({ host: linux, drivers: drivers({ 'apple-container': true, podman: true }) }) + expect(res.driver.name).toBe('podman') + }) + + it('throws NoEngineError when nothing is available', async () => { + await expect(resolveEngine({ host: linux, drivers: drivers({}) })).rejects.toBeInstanceOf(NoEngineError) + }) +}) + +describe('resolveEngine (explicit --engine)', () => { + it('honors a supported + available request', async () => { + const res = await resolveEngine({ requested: 'docker', host: linux, drivers: drivers({ docker: true }) }) + expect(res.driver.name).toBe('docker') + }) + + it('rejects an engine unsupported on the host', async () => { + await expect( + resolveEngine({ requested: 'apple-container', host: linux, drivers: drivers({ 'apple-container': true }) }), + ).rejects.toBeInstanceOf(EngineUnsupportedError) + }) + + it('rejects a supported but unavailable engine', async () => { + await expect( + resolveEngine({ requested: 'docker', host: linux, drivers: drivers({ docker: false }) }), + ).rejects.toBeInstanceOf(EngineUnavailableError) + }) +}) + +describe('surveyEngines', () => { + it('marks apple-container unsupported on linux and hides it from recommendation', async () => { + const statuses = await surveyEngines({ host: linux, drivers: drivers({ docker: true, 'apple-container': true }) }) + const apple = statuses.find((s) => s.name === 'apple-container')! + expect(apple.platform.supported).toBe(false) + expect(apple.recommended).toBe(false) + const recommended = statuses.find((s) => s.recommended) + expect(recommended?.name).toBe('docker') + }) + + it('exposes capability notes for degraded engines (real drivers)', async () => { + const statuses = await surveyEngines({ host: linux, drivers: createAllDrivers(), skipDetect: true }) + const podman = statuses.find((s) => s.name === 'podman')! + expect(podman.capabilities.userNamespaces.support).toBe('caveated') + const apple = statuses.find((s) => s.name === 'apple-container')! + expect(apple.capabilities.apparmor.support).toBe('unsupported') + expect(apple.capabilities.tmpfs.support).toBe('unsupported') + }) +}) diff --git a/packages/core/test/unit/__snapshots__/containerfile.test.ts.snap b/packages/core/test/unit/__snapshots__/containerfile.test.ts.snap new file mode 100644 index 0000000..f9bde90 --- /dev/null +++ b/packages/core/test/unit/__snapshots__/containerfile.test.ts.snap @@ -0,0 +1,110 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`generateContainerfile > is stable for a fixed selection (snapshot) 1`] = ` +"# syntax=docker/dockerfile:1.8 +# check=error=true + +# Base image: Debian 13 (trixie) — current Debian stable +FROM debian:trixie + +# Base packages (git replaces the old devcontainer git feature) +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \\ + bash-completion \\ + build-essential \\ + ca-certificates \\ + curl \\ + git \\ + gnupg \\ + jq \\ + locales \\ + pkg-config \\ + sudo \\ + unzip \\ + vim \\ + wget \\ + zsh \\ + && rm -rf /var/lib/apt/lists/* + +# Create the non-root 'vscode' user (uid 1000) with zsh + passwordless sudo +RUN useradd --create-home --shell /usr/bin/zsh --uid 1000 vscode \\ + && usermod -aG sudo vscode \\ + && echo 'vscode ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/vscode \\ + && chmod 0440 /etc/sudoers.d/vscode \\ + && mkdir -p /workspace && chown vscode:vscode /workspace + +# Install Python build dependencies +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \\ + python3-pip \\ + libpython3-dev \\ + python3-dev \\ + python3-venv \\ + && rm -rf /var/lib/apt/lists/* + +# Switch to vscode (drop privileges) +USER vscode +WORKDIR /home/vscode +ENV HOME=/home/vscode +# Update PATH +ENV USR_LOCAL_BIN=/usr/local/bin +ENV LOCAL_BIN=\${HOME}/.local/bin +ENV PNPM_HOME=\${HOME}/.local/share/pnpm +ENV PATH=\${PATH}:\${USR_LOCAL_BIN}:\${LOCAL_BIN}:\${PNPM_HOME} +# Ensure ~/.local/bin, ~/.zshrc, and the dcw install-report dir exist +RUN mkdir -p \${HOME}/.local/bin \${HOME}/.dcw && touch \${HOME}/.zshrc + +# OpenSSH server for editor attach over SSH (rootless \`sshd -i\`). +RUN sudo apt-get update \\ + && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \\ + openssh-server \\ + && sudo rm -rf /var/lib/apt/lists/* \\ + && mkdir -p \${HOME}/.ssh && chmod 700 \${HOME}/.ssh \\ + && ssh-keygen -q -t ed25519 -N "" -f \${HOME}/.ssh/ssh_host_ed25519_key \\ + && touch \${HOME}/.ssh/authorized_keys && chmod 600 \${HOME}/.ssh/authorized_keys \\ + && printf "%s\\n" \\ + "HostKey /home/vscode/.ssh/ssh_host_ed25519_key" \\ + "PidFile /home/vscode/.ssh/sshd.pid" \\ + "UsePAM no" \\ + "PasswordAuthentication no" \\ + "PubkeyAuthentication yes" \\ + "AuthorizedKeysFile /home/vscode/.ssh/authorized_keys" \\ + "Subsystem sftp internal-sftp" \\ + "AllowTcpForwarding yes" \\ + "PermitUserEnvironment no" \\ + "StrictModes no" \\ + "LogLevel QUIET" \\ + | sudo tee /etc/ssh/sshd_config.dcw > /dev/null + +# Install uv +RUN curl -LsSf https://astral.sh/uv/install.sh | sh +ENV UV_LOCAL_BIN=$HOME/.cargo/bin +ENV PATH=\${PATH}:\${USR_LOCAL_BIN}:\${LOCAL_BIN}:\${PNPM_HOME}:\${UV_LOCAL_BIN} +# Install Python 3.12 with uv +RUN uv python install 3.12 + +# Use zsh for subsequent RUN commands +ENV SHELL=/usr/bin/zsh +SHELL ["/bin/zsh", "-ic"] + +# Install rust +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +ENV PATH="$HOME/.cargo/bin:$PATH" + +# Install foundry (best-effort) +# Install Foundry +RUN ( curl -fsSL https://foundry.paradigm.xyz | zsh && echo 'export PATH="$HOME/.foundry/bin:$PATH"' >> ~/.zshrc && export PATH="$HOME/.foundry/bin:$PATH" && ~/.foundry/bin/foundryup ) && echo "foundry=ok" >> /home/vscode/.dcw/report || { echo "foundry=fail" >> /home/vscode/.dcw/report; echo "dcw: foundry install failed (continuing)" >&2; } + +# Install slither (best-effort) +# Install Slither (via uv tool) +RUN ( uv tool install slither-analyzer ) && echo "slither=ok" >> /home/vscode/.dcw/report || { echo "slither=fail" >> /home/vscode/.dcw/report; echo "dcw: slither install failed (continuing)" >&2; } + +# Final setup +RUN echo 'Development environment ready!' && \\ + echo 'Tools installed:' && \\ + ls -la $HOME/.local/bin/ || true + +# Tool install report (best-effort installs) +RUN echo '--- dcw tool install report ---' && cat /home/vscode/.dcw/report 2>/dev/null || true + +WORKDIR /workspace +" +`; diff --git a/packages/core/test/unit/agent-airgap.test.ts b/packages/core/test/unit/agent-airgap.test.ts new file mode 100644 index 0000000..c011fca --- /dev/null +++ b/packages/core/test/unit/agent-airgap.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { assessAirgap } from '../../src/commands/agent.js' +import { EnvSpecSchema } from '../../src/spec/env-spec.js' +import { SCHEMA_VERSION, type EnvManifest } from '../../src/state/manifest.js' + +const NOW = '2026-06-11T00:00:00.000Z' + +function manifest(hardening: string[], droppedHardening?: string[], appliedFlags: string[] = []): EnvManifest { + const spec = EnvSpecSchema.parse({ name: 'demo', hardening }) + return { + schemaVersion: SCHEMA_VERSION, + name: 'demo', + createdAt: NOW, + updatedAt: NOW, + spec, + resolved: { requiredTools: [], hardeningKeys: spec.hardening }, + engine: 'docker', + image: { tag: 'dcw/demo:latest', imageId: 'sha256:x', containerfileHash: 'h', builtAt: NOW }, + container: droppedHardening + ? { id: 'cid', name: 'dcw-demo', status: 'running', startedAt: NOW, appliedFlags, droppedHardening } + : null, + } +} + +describe('assessAirgap — do not forward secrets into a container we wrongly call air-gapped', () => { + it('reports no air-gap when none was requested', () => { + expect(assessAirgap(manifest(['drop-caps'], [], ['--cap-drop=ALL']))).toBe('none') + }) + + it('reports an enforced air-gap only when --network=none is actually in the applied flags', () => { + expect(assessAirgap(manifest(['network-none'], [], ['--network=none']))).toBe('enforced') + }) + + it('reports DROPPED when the flags show the air-gap never reached the engine', () => { + // Nothing recorded as dropped, but the container was demonstrably launched + // without the flag — trust the evidence, not the absence of a complaint. + expect(assessAirgap(manifest(['network-none'], [], ['--cap-drop=ALL']))).toBe('dropped') + }) + + it('reports UNKNOWN when no flags were recorded, rather than assuming success', () => { + // Absence of evidence must not read as evidence of enforcement: callers treat + // 'unknown' as unsafe, because credentials are about to be forwarded. + expect(assessAirgap(manifest(['network-none'], []))).toBe('unknown') + }) + + it('reports a DROPPED air-gap when the engine could not apply it', () => { + // e.g. Apple Containers: --network=none is not honored, so the container is + // online even though the user asked for an air-gap. + expect(assessAirgap(manifest(['network-none'], ['network-none']))).toBe('dropped') + }) + + it('treats a not-yet-started environment as unknown, never as enforced', () => { + expect(assessAirgap(manifest(['network-none']))).toBe('unknown') + }) +}) diff --git a/packages/core/test/unit/agent-env.test.ts b/packages/core/test/unit/agent-env.test.ts new file mode 100644 index 0000000..68be121 --- /dev/null +++ b/packages/core/test/unit/agent-env.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { parseEnvForwards } from '../../src/commands/agent.js' +import { ValidationError } from '../../src/errors.js' + +describe('dcw agent --env (codex-security CS#4)', () => { + it('forwards a bare NAME from the host environment', () => { + expect(parseEnvForwards(['GITHUB_TOKEN'], { GITHUB_TOKEN: 'ghp_x' })).toEqual({ GITHUB_TOKEN: 'ghp_x' }) + expect(parseEnvForwards(['MISSING'], {})).toEqual({}) + }) + + it('rejects inline NAME=value without echoing the value', () => { + let err: unknown + try { + parseEnvForwards(['GITHUB_TOKEN=ghp_supersecret'], {}) + } catch (e) { + err = e + } + expect(err).toBeInstanceOf(ValidationError) + expect((err as Error).message).not.toContain('ghp_supersecret') + expect((err as Error).message).toContain('GITHUB_TOKEN') + }) + + it('rejects empty and malformed names', () => { + expect(() => parseEnvForwards(['=x'], {})).toThrow(ValidationError) + expect(() => parseEnvForwards([''], {})).toThrow(ValidationError) + expect(() => parseEnvForwards(['BAD NAME'], {})).toThrow(ValidationError) + }) +}) diff --git a/packages/core/test/unit/apple-container-caps.test.ts b/packages/core/test/unit/apple-container-caps.test.ts new file mode 100644 index 0000000..0db4190 --- /dev/null +++ b/packages/core/test/unit/apple-container-caps.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { planEnvironment } from '../../src/core/plan.js' +import { AppleContainerDriver } from '../../src/engine/drivers/apple-container.js' +import { enforceStrict, translate } from '../../src/hardening/translator.js' +import { EnvSpecSchema } from '../../src/spec/env-spec.js' + +const appleCaps = new AppleContainerDriver().capabilities + +function applied(hardening: string[]) { + const plan = planEnvironment(EnvSpecSchema.parse({ name: 'demo', hardening })) + return translate(plan.effects, appleCaps, 'apple-container') +} + +// NOTE: these assert dcw's declared stance toward Apple Containers, not the engine's +// behaviour — a unit test cannot observe the runtime. The stance itself was set from +// live probes against `container` CLI 1.0.0, recorded in apple-container.ts. +describe('Apple Containers capability stance', () => { + it('emits --cap-drop rather than discarding it', () => { + // Live probe: `--cap-drop ALL` takes CapEff from 00000000a80425fb to + // 0000000000000000, so declaring it unsupported threw away real hardening. + const t = applied(['drop-caps']) + expect(t.flags).toContain('--cap-drop=ALL') + expect(t.dropped.map((e) => e.kind)).not.toContain('drop-cap') + }) + + it('drops the controls the VM runtime genuinely lacks', () => { + const kinds = applied(['network-none', 'no-new-privs', 'apparmor']).dropped.map((e) => e.kind) + expect(kinds).toEqual(expect.arrayContaining(['network-none', 'no-new-privs', 'apparmor'])) + }) + + it('drops tmpfs instead of emitting a lossy bare path', () => { + // `container run --tmpfs ` treats the WHOLE string as the mount path, so the + // Docker form mounts a directory literally named "/tmp:rw,noexec,...". Emitting + // a bare path instead would silently discard uid=1000/gid=1000/mode=0700, giving + // root-owned empty tmpfs over /home/vscode/.local and .ssh — hiding baked tools + // and breaking `dcw attach`. Dropping it (loudly) is the safe stance. + const t = applied(['secure-tmp']) + expect(t.flags).toEqual([]) + expect(t.dropped.map((e) => e.kind)).toContain('tmpfs') + }) + + it('drops read-only rootfs, which is unusable without those tmpfs mounts', () => { + const t = applied(['readonly-os']) + expect(t.flags).not.toContain('--read-only') + expect(t.dropped.map((e) => e.kind)).toContain('readonly-rootfs') + }) + + it('fails --strict for anything it had to drop', () => { + expect(() => enforceStrict(applied(['readonly-os']))).toThrow(/cannot honor/) + expect(() => enforceStrict(applied(['network-none']))).toThrow(/cannot honor/) + // cap-drop alone is fully honored, so strict succeeds. + expect(() => enforceStrict(applied(['drop-caps']))).not.toThrow() + }) +}) diff --git a/packages/core/test/unit/apple-list-parse.test.ts b/packages/core/test/unit/apple-list-parse.test.ts new file mode 100644 index 0000000..fc04339 --- /dev/null +++ b/packages/core/test/unit/apple-list-parse.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { parseAppleList } from '../../src/engine/drivers/apple-container.js' + +// Captured verbatim from `container list --format json --all` on container CLI 1.0.0. +const REAL_OUTPUT = JSON.stringify([ + { + id: 'buildkit', + status: { networks: [], state: 'stopped' }, + configuration: { + id: 'buildkit', + labels: { 'com.apple.container.plugin': 'builder' }, + image: { reference: 'ghcr.io/apple/container-builder-shim/builder:0.12.0' }, + }, + }, + { + id: 'dcw-demo', + status: { networks: [{ ipv4Address: '192.168.64.2/24' }], startedDate: '2026-08-20T16:42:28Z', state: 'running' }, + configuration: { + id: 'dcw-demo', + labels: { 'dcw.env': 'demo' }, + image: { reference: 'docker.io/library/alpine:latest' }, + }, + }, +]) + +describe('parseAppleList — Apple Containers uses a nested schema, not docker’s flat one', () => { + it('reads name, status and image out of the nested shape', () => { + const rows = parseAppleList(REAL_OUTPUT) + const demo = rows.find((r) => r.id === 'dcw-demo')! + // Reading docker's field names off this gave name:'' and status:'[object Object]', + // which made `dcw ls` call a running container "absent". + expect(demo.name).toBe('dcw-demo') + expect(demo.status).toBe('running') + expect(demo.image).toBe('docker.io/library/alpine:latest') + expect(demo.labels['dcw.env']).toBe('demo') + }) + + it('applies the label filter client-side (container list has no --filter)', () => { + // Without this, every env matched the unrelated always-present `buildkit` + // container, defeating the presence guard in stop/rm. + const rows = parseAppleList(REAL_OUTPUT, 'dcw.env=demo') + expect(rows.map((r) => r.id)).toEqual(['dcw-demo']) + }) + + it('returns nothing for a label that matches no container', () => { + expect(parseAppleList(REAL_OUTPUT, 'dcw.env=missing')).toEqual([]) + }) + + it('is recognised as running by the real attach liveness check', async () => { + const { isContainerRunning } = await import('../../src/core/ssh/attach.js') + const rows = parseAppleList(REAL_OUTPUT, 'dcw.env=demo') + // Drive the actual helper rather than re-asserting its regex here. + const driver = { ps: async () => rows } as unknown as Parameters[0] + expect(await isContainerRunning(driver, 'demo')).toBe(true) + + const stopped = parseAppleList(REAL_OUTPUT, 'com.apple.container.plugin=builder') + const stoppedDriver = { ps: async () => stopped } as unknown as Parameters[0] + expect(await isContainerRunning(stoppedDriver, 'buildkit')).toBe(false) + }) + + it('skips a malformed row instead of taking down ls/stop/rm', () => { + // One bad row in otherwise-valid JSON used to throw on `row.configuration`. + const withNull = '[null, ' + REAL_OUTPUT.slice(1) + const rows = parseAppleList(withNull) + expect(rows.map((r) => r.id)).toContain('dcw-demo') + expect(parseAppleList('[null]')).toEqual([]) + expect(parseAppleList('[1, "x", null]')).toEqual([]) + }) + + it('degrades to an empty list on malformed output', () => { + expect(parseAppleList('not json')).toEqual([]) + expect(parseAppleList('{}')).toEqual([]) + }) +}) diff --git a/packages/core/test/unit/attach-ssh.test.ts b/packages/core/test/unit/attach-ssh.test.ts new file mode 100644 index 0000000..99e752b --- /dev/null +++ b/packages/core/test/unit/attach-ssh.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest' +import { planEnvironment } from '../../src/core/plan.js' +import { renderEntry } from '../../src/core/ssh/ssh-config.js' +import { provisionContainerSsh } from '../../src/core/ssh/provision.js' +import { SSH_CONTAINER_PORT, upEnvironment } from '../../src/core/up-pipeline.js' +import { caps } from '../../src/engine/drivers/capabilities.js' +import { EnvSpecSchema, type EnvSpec } from '../../src/spec/env-spec.js' +import { SCHEMA_VERSION, type EnvManifest } from '../../src/state/manifest.js' +import { FakeDriver } from '../engine/fake-driver.js' + +const NOW = '2026-06-11T00:00:00.000Z' + +function spec(overrides: Partial = {}): EnvSpec { + return EnvSpecSchema.parse({ name: 'demo', hardening: ['drop-caps'], ...overrides }) +} + +function builtManifest(s: EnvSpec): EnvManifest { + return { + schemaVersion: SCHEMA_VERSION, + name: s.name, + createdAt: NOW, + updatedAt: NOW, + spec: s, + resolved: { requiredTools: [], hardeningKeys: s.hardening }, + engine: 'docker', + image: { tag: `dcw/${s.name}:latest`, id: 'sha256:x', containerfileHash: 'h', builtAt: NOW }, + container: null, + } +} + +async function up(s: EnvSpec, sshPublishPort?: number) { + const driver = new FakeDriver({ name: 'docker' }) + const plan = planEnvironment(s) + const out = await upEnvironment({ + manifest: builtManifest(s), + plan, + driver, + capabilities: caps(), + engineName: 'docker', + workspaceDir: '/tmp/ws', + sshPublishPort, + now: NOW, + }) + return { driver, out } +} + +describe('dcw attach — published-port SSH', () => { + it('publishes the container sshd on loopback only, never all interfaces', async () => { + const { out } = await up(spec(), 2222) + const i = out.runSpec.flags.indexOf('-p') + expect(i).toBeGreaterThanOrEqual(0) + const mapping = out.runSpec.flags[i + 1]! + + // A bare "2222:2222" makes Docker bind 0.0.0.0, exposing an SSH server on an + // untrusted-code container to the whole LAN. It must be pinned to loopback. + expect(mapping).toBe(`127.0.0.1:2222:${SSH_CONTAINER_PORT}`) + expect(mapping.startsWith('127.0.0.1:')).toBe(true) + }) + + it('records the published port on the manifest', async () => { + const { out } = await up(spec(), 2222) + expect(out.manifest.container?.ssh).toEqual({ mode: 'port', port: 2222 }) + }) + + it('refuses to publish a port for a network-none (airgapped) environment', async () => { + await expect(up(spec({ hardening: ['network-none'] }), 2222)).rejects.toThrow(/network-none/) + }) + + it('publishes no port and records no ssh state in exec-proxy mode', async () => { + const { out } = await up(spec()) + expect(out.runSpec.flags).not.toContain('-p') + expect(out.manifest.container?.ssh).toBeUndefined() + }) +}) + +describe('ssh_config rendering', () => { + it('pins host key checking and uses dcw-managed identity + known_hosts', () => { + const block = renderEntry({ + name: 'demo', + mode: 'exec', + identityFile: '/k/id_ed25519', + knownHostsFile: '/k/known_hosts', + proxyCommand: 'dcw ssh-proxy demo', + }) + expect(block).toContain('Host dcw-demo') + expect(block).toContain('IdentitiesOnly yes') + expect(block).toContain('UserKnownHostsFile /k/known_hosts') + // Must never disable host-key verification outright. + expect(block).toContain('StrictHostKeyChecking accept-new') + expect(block).not.toMatch(/StrictHostKeyChecking\s+no/) + }) + + it('uses localhost + the published port in port mode', () => { + const block = renderEntry({ + name: 'demo', + mode: 'port', + identityFile: '/k/id', + knownHostsFile: '/k/kh', + port: 2222, + }) + expect(block).toContain('HostName localhost') + expect(block).toContain('Port 2222') + expect(block).not.toContain('ProxyCommand') + }) +}) + +describe('container ssh provisioning', () => { + it('passes the public key over stdin, never through argv', async () => { + const driver = new FakeDriver({ name: 'docker' }) + const publicKey = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI+injected dcw-attach' + await provisionContainerSsh({ driver, container: 'dcw-demo', publicKey, hostAlias: 'dcw-demo' }) + + expect(driver.execs.length).toBeGreaterThan(0) + for (const e of driver.execs) { + for (const arg of e.cmd) { + expect(arg).not.toContain(publicKey) + expect(arg).not.toContain('AAAAC3NzaC1lZDI1NTE5') + } + } + }) + + it('runs provisioning as the unprivileged vscode user', async () => { + const driver = new FakeDriver({ name: 'docker' }) + await provisionContainerSsh({ driver, container: 'dcw-demo', publicKey: 'ssh-ed25519 AAAA x', hostAlias: 'dcw-demo' }) + for (const e of driver.execs) expect(e.user).toBe('vscode') + }) +}) + +describe('dcw attach — flag validation', () => { + it('rejects a relative --folder that would build a malformed ssh:// URL', async () => { + const { default: Attach } = await import('../../src/commands/attach.js') + await expect(Attach.run(['demo', '--folder', 'work', '--print'])).rejects.toThrow( + /--folder must be an absolute path/, + ) + }) +}) + +describe('dcw attach --port on an air-gapped environment', () => { + it('refuses before destroying the running container', async () => { + const fs = await import('node:fs/promises') + const os = await import('node:os') + const path = await import('node:path') + const { saveManifest } = await import('../../src/state/store.js') + const { default: Attach } = await import('../../src/commands/attach.js') + + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dcw-attach-')) + const prevC = process.env.XDG_CONFIG_HOME + const prevS = process.env.XDG_STATE_HOME + process.env.XDG_CONFIG_HOME = path.join(tmp, 'config') + process.env.XDG_STATE_HOME = path.join(tmp, 'state') + try { + const s = EnvSpecSchema.parse({ name: 'gapped', hardening: ['network-none'] }) + await saveManifest({ + ...builtManifest(s), + container: { id: 'cid', name: 'dcw-gapped', status: 'running', startedAt: NOW, appliedFlags: [], droppedHardening: [] }, + }) + // Must fail on the plan alone — no engine call, so a live container is never + // force-removed on the way to an error that was knowable up front. + await expect(Attach.run(['gapped', '--port', '2222', '--print'])).rejects.toThrow(/network-none/) + } finally { + if (prevC === undefined) delete process.env.XDG_CONFIG_HOME + else process.env.XDG_CONFIG_HOME = prevC + if (prevS === undefined) delete process.env.XDG_STATE_HOME + else process.env.XDG_STATE_HOME = prevS + await fs.rm(tmp, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/core/test/unit/capabilities.test.ts b/packages/core/test/unit/capabilities.test.ts new file mode 100644 index 0000000..593bbed --- /dev/null +++ b/packages/core/test/unit/capabilities.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { CAPABILITY_KEYS, daemonReportsApparmor, dockerCaps } from '../../src/engine/drivers/capabilities.js' +import { createAllDrivers, createDriver } from '../../src/engine/registry.js' + +describe('engine capabilities (#4 — fail-closed coverage)', () => { + it('every driver addresses every capability key with a valid support level', () => { + for (const driver of createAllDrivers()) { + for (const key of CAPABILITY_KEYS) { + const cap = driver.capabilities[key] + expect(cap, `${driver.name}.${key}`).toBeDefined() + expect(['supported', 'caveated', 'unsupported']).toContain(cap.support) + } + } + }) + + it('apple-container does not claim Docker-only security controls as supported', () => { + const caps = createDriver('apple-container').capabilities + // The `container` CLI exposes none of these; a silent 'supported' default + // (the original networkNone bug) would let a paranoid/airgapped env pass + // --strict while the control is not actually applied. + const mustBeUnsupported = [ + 'networkNone', 'noNewPrivs', 'apparmor', 'seccomp', 'sysctl', + // tmpfs options are not parsed by this CLI, and read-only rootfs is unusable + // without the uid-mapped tmpfs mounts it is always paired with. + 'tmpfs', 'readOnlyRootfs', + ] as const + for (const key of mustBeUnsupported) { + expect(caps[key].support, key).toBe('unsupported') + } + + // Conversely, cap-drop IS exposed and enforced by this CLI (verified against + // container 1.0.0: CapEff -> 0), so declaring it unsupported would discard real + // hardening. Fail-closed must not become fail-blind. + expect(caps.capDrop.support).toBe('supported') + }) +}) + +describe('dockerCaps — AppArmor depends on the daemon, not the client host', () => { + it('treats AppArmor as enforced when the daemon reports it', () => { + const c = dockerCaps(true) + expect(c.apparmor.support).toBe('supported') + expect(c.apparmor.enforced).toBeUndefined() + }) + + it('marks AppArmor unenforced when the daemon does not report it', () => { + // e.g. the Linux VM Docker/OrbStack run on macOS: the flag is accepted, but + // `docker inspect` comes back with an empty AppArmorProfile. + const c = dockerCaps(false) + expect(c.apparmor.support).toBe('caveated') + expect(c.apparmor.enforced).toBe(false) + expect(c.apparmor.note).toMatch(/not enforced/) + }) + + it('fails closed when the daemon could not be probed', () => { + // Claiming an unverified control is the failure --strict exists to prevent. + const c = dockerCaps(undefined) + expect(c.apparmor.support).toBe('caveated') + expect(c.apparmor.enforced).toBe(false) + }) + + it('leaves every other Docker control fully supported either way', () => { + for (const c of [dockerCaps(true), dockerCaps(false)]) { + for (const key of ['capDrop', 'readOnlyRootfs', 'networkNone', 'noNewPrivs', 'seccomp', 'tmpfs'] as const) { + expect(c[key].support, key).toBe('supported') + } + } + }) + + it('reads AppArmor availability out of real `docker info` SecurityOptions', () => { + // Captured from OrbStack 29.4.0 on macOS (no apparmor) and a Linux daemon. + expect(daemonReportsApparmor('["name=seccomp,profile=builtin","name=cgroupns"]')).toBe(false) + expect(daemonReportsApparmor('["name=apparmor","name=seccomp,profile=builtin"]')).toBe(true) + expect(daemonReportsApparmor('')).toBe(false) + }) +}) diff --git a/packages/core/test/unit/containerfile.test.ts b/packages/core/test/unit/containerfile.test.ts new file mode 100644 index 0000000..0fa5ec3 --- /dev/null +++ b/packages/core/test/unit/containerfile.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import { generateContainerfile } from '../../src/containerfile/generate.js' + +describe('generateContainerfile', () => { + it('emits the syntax/check directives and a plain Debian base', () => { + const cf = generateContainerfile({ selections: {} }) + expect(cf.startsWith('# syntax=docker/dockerfile:1.8')).toBe(true) + expect(cf).toContain('# check=error=true') + expect(cf).toContain('FROM debian:trixie') + expect(cf).not.toContain('mcr.microsoft.com') + }) + + it('reproduces the vscode user / zsh / PATH contract', () => { + const cf = generateContainerfile({ selections: {} }) + expect(cf).toContain('useradd --create-home --shell /usr/bin/zsh --uid 1000 vscode') + expect(cf).toContain('USER vscode') + expect(cf).toContain('ENV HOME=/home/vscode') + expect(cf).toContain('SHELL ["/bin/zsh", "-ic"]') + expect(cf).toContain('git \\') // git via apt, not a devcontainer feature + }) + + it('does not generate any devcontainer.json artifacts', () => { + const cf = generateContainerfile({ selections: { frameworks: ['foundry'] } }) + expect(cf).not.toContain('devcontainer.json') + expect(cf).not.toContain('customizations') + expect(cf).not.toContain('remoteUser') + expect(cf).not.toContain('workspaceMount') + }) + + it('installs Python (uv) only when required', () => { + const withPy = generateContainerfile({ selections: { languages: ['solidity'] } }) + expect(withPy).toContain('uv python install 3.12') + expect(withPy).toContain('python3-venv') + const withoutPy = generateContainerfile({ selections: { coreLanguages: ['rust'] } }) + expect(withoutPy).not.toContain('uv python install 3.12') + }) + + it('orders runtimes before other tools', () => { + const cf = generateContainerfile({ selections: { frameworks: ['foundry'], fuzzingAndTesting: ['ityfuzz'] } }) + expect(cf.indexOf('Install rust')).toBeGreaterThan(-1) + expect(cf.indexOf('rustup.rs')).toBeLessThan(cf.indexOf('foundry.paradigm.xyz')) + expect(cf.indexOf('rustup.rs')).toBeLessThan(cf.indexOf('ity.fuzz.land')) + }) + + it('adds the echidna multi-stage build and copy when selected', () => { + const cf = generateContainerfile({ selections: { fuzzingAndTesting: ['echidna'] } }) + expect(cf).toContain('AS echidna') + expect(cf).toContain('COPY --from=echidna /usr/local/bin/echidna /usr/local/bin/echidna') + }) + + it('installs AI coding agents via npm and pulls Node', () => { + const cf = generateContainerfile({ selections: { aiAgents: ['claude', 'codex', 'opencode'] } }) + expect(cf).toContain('npm install -g @anthropic-ai/claude-code') + expect(cf).toContain('npm install -g @openai/codex') + expect(cf).toContain('npm install -g opencode-ai') + // node runtime is pulled in as a dependency and installed before the agents + expect(cf.indexOf('install.sh')).toBeLessThan(cf.indexOf('@anthropic-ai/claude-code')) + }) + + it('emits an optional git clone step', () => { + const cf = generateContainerfile({ + selections: {}, + gitRepository: { enabled: true, url: 'https://github.com/foo/bar', branch: 'main' }, + }) + expect(cf).toContain('git clone --branch main https://github.com/foo/bar /home/vscode/repos/project') + }) + + it('refuses to splice an unsafe git url/branch into the clone line (#2)', () => { + expect(() => + generateContainerfile({ + selections: {}, + gitRepository: { enabled: true, url: 'https://x\nRUN echo pwned' }, + }), + ).toThrow(/unsafe characters/) + expect(() => + generateContainerfile({ + selections: {}, + gitRepository: { enabled: true, url: 'https://github.com/foo/bar', branch: 'main; id' }, + }), + ).toThrow(/unsafe characters/) + }) + + it('ends with WORKDIR /workspace', () => { + const cf = generateContainerfile({ selections: {} }) + expect(cf.trimEnd().endsWith('WORKDIR /workspace')).toBe(true) + }) + + it('installs the SSH server by default and omits it under --no-ssh', () => { + const withSsh = generateContainerfile({ selections: {} }) + expect(withSsh).toContain('openssh-server') + expect(withSsh).toContain('ssh_host_ed25519_key') + + const noSsh = generateContainerfile({ selections: {}, ssh: false }) + expect(noSsh).not.toContain('openssh-server') + }) + + it('is stable for a fixed selection (snapshot)', () => { + const cf = generateContainerfile({ + selections: { coreLanguages: ['rust'], frameworks: ['foundry'], securityTooling: ['slither'] }, + }) + expect(cf).toMatchSnapshot() + }) +}) + +describe('codex-security findings', () => { + it('installs the libssl1.1 shim over HTTPS with a pinned SHA-256 (CS#1)', () => { + const cf = generateContainerfile({ selections: { frameworks: ['foundry'], fuzzingAndTesting: ['ityfuzz'] } }) + expect(cf).not.toMatch(/http:\/\//) + expect(cf).toContain("--proto '=https'") + expect(cf).toContain('sha256sum -c -') + expect(cf).toContain('aadf8b4b197335645b230c2839b4517aa444fd2e8f434e5438c48a18857988f7') + // digest check must precede dpkg + expect(cf.indexOf('sha256sum -c -')).toBeLessThan(cf.indexOf('sudo dpkg -i /tmp/libssl1.1.deb')) + }) + + it('never emits a plain-HTTP download anywhere in a kitchen-sink Containerfile (CS#1)', () => { + const cf = generateContainerfile({ + selections: { + coreLanguages: ['rust', 'go', 'node'], + languages: ['solidity', 'vyper'], + frameworks: ['foundry', 'hardhat'], + fuzzingAndTesting: ['ityfuzz', 'echidna', 'medusa'], + securityTooling: ['slither', 'aderyn', 'heimdall'], + aiAgents: ['claude'], + }, + }) + expect(cf).not.toMatch(/\bhttp:\/\//) + }) +}) diff --git a/packages/core/test/unit/dependency-resolver.test.ts b/packages/core/test/unit/dependency-resolver.test.ts new file mode 100644 index 0000000..2d1afb3 --- /dev/null +++ b/packages/core/test/unit/dependency-resolver.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { resolveTools } from '../../src/domain/dependency-resolver.js' + +describe('resolveTools', () => { + it('pulls rust for foundry', () => { + const r = resolveTools({ frameworks: ['foundry'] }) + expect(r.all).toContain('rust') + expect(r.all).toContain('foundry') + expect(r.runtimes).toEqual(['rust']) + expect(r.tools).toContain('foundry') + }) + + it('pulls node for hardhat', () => { + const r = resolveTools({ frameworks: ['hardhat'] }) + expect(r.all).toEqual(expect.arrayContaining(['node', 'hardhat'])) + }) + + it('pulls python + solc-select for solidity', () => { + const r = resolveTools({ languages: ['solidity'] }) + expect(r.needsPython).toBe(true) + expect(r.all).toContain('solc-select') + }) + + it('pulls go for echidna and medusa', () => { + expect(resolveTools({ fuzzingAndTesting: ['echidna'] }).all).toContain('go') + expect(resolveTools({ fuzzingAndTesting: ['medusa'] }).all).toContain('go') + }) + + it('pulls rust for ityfuzz, aderyn, heimdall', () => { + expect(resolveTools({ fuzzingAndTesting: ['ityfuzz'] }).all).toContain('rust') + expect(resolveTools({ fuzzingAndTesting: ['aderyn'] }).all).toContain('rust') + expect(resolveTools({ securityTooling: ['heimdall'] }).all).toContain('rust') + }) + + it('pulls python for halmos and slither family', () => { + expect(resolveTools({ fuzzingAndTesting: ['halmos'] }).needsPython).toBe(true) + expect(resolveTools({ securityTooling: ['slither'] }).needsPython).toBe(true) + expect(resolveTools({ securityTooling: ['panoramix'] }).needsPython).toBe(true) + }) + + it('does NOT add a VS Code extension for panoramix (editor-agnostic)', () => { + const r = resolveTools({ securityTooling: ['panoramix'] }) + expect(r.all).not.toContain('tintinweb.vscode-decompiler') + // every key must be a real install command + for (const t of r.all) expect(t).not.toContain('vscode') + }) + + it('orders runtimes rust, go, node and excludes python/runtimes from tools', () => { + const r = resolveTools({ coreLanguages: ['node', 'go', 'rust', 'python'], frameworks: ['foundry'] }) + expect(r.runtimes).toEqual(['rust', 'go', 'node']) + expect(r.tools).not.toContain('python') + expect(r.tools).not.toContain('rust') + expect(r.tools).toContain('foundry') + }) + + it('dedupes when multiple selections require the same runtime', () => { + const r = resolveTools({ coreLanguages: ['rust'], frameworks: ['foundry'], fuzzingAndTesting: ['ityfuzz'] }) + expect(r.all.filter((t) => t === 'rust')).toHaveLength(1) + }) + + it('pulls node for AI coding agents', () => { + const r = resolveTools({ aiAgents: ['claude'] }) + expect(r.all).toEqual(expect.arrayContaining(['node', 'claude'])) + expect(r.runtimes).toEqual(['node']) + expect(r.tools).toContain('claude') + }) + + it('resolves all three AI agents', () => { + const r = resolveTools({ aiAgents: ['claude', 'codex', 'opencode'] }) + expect(r.all).toEqual(expect.arrayContaining(['claude', 'codex', 'opencode', 'node'])) + // node pulled once despite three agents requiring it + expect(r.all.filter((t) => t === 'node')).toHaveLength(1) + }) + + it('returns empty for no selections', () => { + const r = resolveTools({}) + expect(r.all).toEqual([]) + expect(r.needsPython).toBe(false) + }) +}) diff --git a/packages/core/test/unit/env-name.test.ts b/packages/core/test/unit/env-name.test.ts new file mode 100644 index 0000000..7808cee --- /dev/null +++ b/packages/core/test/unit/env-name.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { isValidEnvName, MAX_ENV_NAME_LENGTH, slugify } from '../../src/util/slug.js' +import { resolveEnvName } from '../../src/cli/context.js' +import { ValidationError } from '../../src/errors.js' + +describe('isValidEnvName', () => { + it('accepts canonical slugs', () => { + for (const name of ['my-env', 'audit', 'a.b_c-1', 'default']) { + expect(isValidEnvName(name), name).toBe(true) + } + }) + + it('rejects traversal, non-canonical, and hidden-file names', () => { + for (const name of ['../../VICTIM', '../foo', 'My Env', 'My/Env', '...', '.hidden', 'UPPER', '@@@']) { + expect(isValidEnvName(name), name).toBe(false) + } + }) + + it('is consistent with slugify for accepted names', () => { + expect(isValidEnvName(slugify('My Env'))).toBe(true) + }) + + it('bounds the name length (#N2)', () => { + expect(isValidEnvName('a'.repeat(MAX_ENV_NAME_LENGTH))).toBe(true) + expect(isValidEnvName('a'.repeat(MAX_ENV_NAME_LENGTH + 1))).toBe(false) + }) +}) + +describe('resolveEnvName (#1 — path-traversal guard)', () => { + it('rejects a traversal positional name before touching the filesystem', async () => { + await expect(resolveEnvName('../../VICTIM')).rejects.toBeInstanceOf(ValidationError) + }) + + it('passes through a valid positional name', async () => { + expect(await resolveEnvName('my-env')).toBe('my-env') + }) +}) diff --git a/packages/core/test/unit/exec-env-resolution.test.ts b/packages/core/test/unit/exec-env-resolution.test.ts new file mode 100644 index 0000000..e524ddf --- /dev/null +++ b/packages/core/test/unit/exec-env-resolution.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Stub the engine-touching exec and the env-name resolver so we can assert how +// `dcw exec` splits its argv into + without a container. +const execInto = vi.fn(async () => 0) +const resolveEnvName = vi.fn(async (positional?: string) => positional ?? 'sole') + +vi.mock('../../src/cli/context.js', () => ({ execInto, resolveEnvName })) + +// Control which env names "exist" for the first-token-is-env-name decision. +const listManifests = vi.fn(async () => [{ name: 'myenv' }]) +vi.mock('../../src/state/store.js', () => ({ listManifests })) + +const { default: Exec } = await import('../../src/commands/exec.js') + +/** Drive Exec.run with a given argv, bypassing oclif's Config-backed parse. */ +async function runExec(argv: string[]): Promise { + const cmd = Object.create(Exec.prototype) as InstanceType + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(cmd as any).parse = async () => ({ argv, flags: {} }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(cmd as any).exit = () => {} + await cmd.run() +} + +beforeEach(() => { + execInto.mockClear() + resolveEnvName.mockClear() + listManifests.mockClear() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('exec env-name resolution (#bug1)', () => { + it('treats the sole token as the command when it is not an existing env (`exec -- forge --version`)', async () => { + await runExec(['forge', '--version']) + expect(resolveEnvName).toHaveBeenCalledWith(undefined) + expect(execInto).toHaveBeenCalledWith(expect.objectContaining({ cmd: ['forge', '--version'] })) + }) + + it('treats the first token as the env name when it names an existing env (`exec myenv -- cmd`)', async () => { + await runExec(['myenv', 'ls', '-la']) + expect(resolveEnvName).toHaveBeenCalledWith('myenv') + expect(execInto).toHaveBeenCalledWith(expect.objectContaining({ name: 'myenv', cmd: ['ls', '-la'] })) + }) + + it('never mistakes a flag-leading token for an env name', async () => { + await runExec(['--help']) + expect(resolveEnvName).toHaveBeenCalledWith(undefined) + expect(execInto).toHaveBeenCalledWith(expect.objectContaining({ cmd: ['--help'] })) + }) + + it('defaults to the sole env with an empty command when no args are given', async () => { + await runExec([]) + expect(resolveEnvName).toHaveBeenCalledWith(undefined) + expect(execInto).toHaveBeenCalledWith(expect.objectContaining({ cmd: [] })) + }) +}) diff --git a/packages/core/test/unit/exec-secrets.test.ts b/packages/core/test/unit/exec-secrets.test.ts new file mode 100644 index 0000000..d7ae525 --- /dev/null +++ b/packages/core/test/unit/exec-secrets.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { DockerDriver } from '../../src/engine/drivers/docker.js' +import type { ExecSpec } from '../../src/engine/types.js' + +/** Expose the protected argv builder for assertion. */ +class ProbeDriver extends DockerDriver { + argvFor(spec: ExecSpec): string[] { + return this.execArgv(spec) + } +} + +describe('execArgv secret handling (#10)', () => { + it('passes -e NAME value-less so secrets never land in argv', () => { + const argv = new ProbeDriver().argvFor({ + container: 'c1', + cmd: ['true'], + interactive: false, + tty: false, + env: { ANTHROPIC_API_KEY: 'sk-super-secret' }, + }) + expect(argv).toContain('-e') + expect(argv).toContain('ANTHROPIC_API_KEY') + expect(argv).not.toContain('ANTHROPIC_API_KEY=sk-super-secret') + expect(argv.join(' ')).not.toContain('sk-super-secret') + }) +}) diff --git a/packages/core/test/unit/flags-to-spec.test.ts b/packages/core/test/unit/flags-to-spec.test.ts new file mode 100644 index 0000000..f453354 --- /dev/null +++ b/packages/core/test/unit/flags-to-spec.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest' +import { flagsToSpec } from '../../src/spec/flags-to-spec.js' +import { ValidationError } from '../../src/errors.js' + +describe('flagsToSpec', () => { + it('builds a spec from selections', () => { + const spec = flagsToSpec({ name: 'Audit Env', frameworks: ['foundry'], securityTooling: ['slither'] }) + expect(spec.name).toBe('audit-env') // slugified + expect(spec.selections.frameworks).toEqual(['foundry']) + expect(spec.engine).toBe('auto') + }) + + it('expands a profile into hardening keys', () => { + const spec = flagsToSpec({ name: 'x', profile: 'hardened' }) + expect(spec.hardening).toContain('drop-caps') + expect(spec.profile).toBe('hardened') + }) + + it('merges manual hardening with the profile', () => { + const spec = flagsToSpec({ name: 'x', profile: 'development', hardening: ['network-none'] }) + expect(spec.hardening).toContain('network-none') + expect(spec.hardening).toContain('apparmor') + }) + + it('normalizes legacy resource-limit aliases', () => { + const spec = flagsToSpec({ name: 'x', hardening: ['resource-limits-medium'] }) + expect(spec.hardening).toContain('resource-limits-standard') + }) + + it('falls back to the cwd-derived name', () => { + const spec = flagsToSpec({ fallbackName: 'My Project' }) + expect(spec.name).toBe('my-project') + }) + + it('captures git repository config', () => { + const spec = flagsToSpec({ name: 'x', gitUrl: 'https://github.com/a/b', gitBranch: 'dev' }) + expect(spec.gitRepository).toEqual({ url: 'https://github.com/a/b', branch: 'dev', enabled: true }) + }) + + it('accepts AI coding agent selections', () => { + const spec = flagsToSpec({ name: 'x', aiAgents: ['claude', 'codex'] }) + expect(spec.selections.aiAgents).toEqual(['claude', 'codex']) + }) + + it('rejects unknown selection values', () => { + expect(() => flagsToSpec({ name: 'x', frameworks: ['truffle'] })).toThrow(ValidationError) + }) + + it('rejects an unknown AI agent', () => { + expect(() => flagsToSpec({ name: 'x', aiAgents: ['cursor'] })).toThrow(ValidationError) + }) + + it('rejects an unknown profile', () => { + expect(() => flagsToSpec({ name: 'x', profile: 'nope' })).toThrow(ValidationError) + }) + + it('rejects unknown hardening keys', () => { + expect(() => flagsToSpec({ name: 'x', hardening: ['make-it-secure'] })).toThrow(ValidationError) + }) + + it('requires a name', () => { + expect(() => flagsToSpec({ frameworks: ['foundry'] })).toThrow(ValidationError) + }) + + it('rejects a name that slugs to a hidden/degenerate identifier (#7)', () => { + // '...' / '.x' would otherwise produce hidden '....json' / '.x.json' manifests. + expect(() => flagsToSpec({ name: '...' })).toThrow(ValidationError) + expect(() => flagsToSpec({ name: '.hidden' })).toThrow(ValidationError) + }) + + it('rejects a git url with shell metacharacters or newlines (#2)', () => { + expect(() => flagsToSpec({ name: 'x', gitUrl: 'https://x\nRUN echo pwned' })).toThrow(ValidationError) + expect(() => flagsToSpec({ name: 'x', gitUrl: 'https://x;rm -rf /' })).toThrow(ValidationError) + expect(() => flagsToSpec({ name: 'x', gitUrl: 'https://x$(whoami)' })).toThrow(ValidationError) + }) + + it('rejects a git branch with metacharacters or a leading dash (#2)', () => { + expect(() => flagsToSpec({ name: 'x', gitUrl: 'https://github.com/a/b', gitBranch: '-x; id' })).toThrow( + ValidationError, + ) + }) + + it('accepts a clean git url + branch (#2)', () => { + const spec = flagsToSpec({ name: 'x', gitUrl: 'https://github.com/org/repo.git', gitBranch: 'main' }) + expect(spec.gitRepository).toMatchObject({ url: 'https://github.com/org/repo.git', branch: 'main' }) + }) + + it('rejects --git-branch without --git-url instead of silently dropping it (#N8)', () => { + expect(() => flagsToSpec({ name: 'x', gitBranch: 'main' })).toThrow(ValidationError) + }) + + it('rejects an over-long name (#N2)', () => { + expect(() => flagsToSpec({ name: 'a'.repeat(300) })).toThrow(ValidationError) + }) + + it('hints at --name when a derived dot-directory name is unusable (#bug4)', () => { + // `create --no-input` in a `.config` dir derives a hidden slug the user can + // only fix by naming the env explicitly — the message must say so. + expect(() => flagsToSpec({ fallbackName: '.config' })).toThrow(/--name/) + expect(() => flagsToSpec({ fallbackName: '.config' })).toThrow(ValidationError) + }) + + it('does not give the --name hint for an explicit bad name (#bug4)', () => { + expect(() => flagsToSpec({ name: '.config' })).toThrow(ValidationError) + expect(() => flagsToSpec({ name: '.config' })).not.toThrow(/pass --name/) + }) + + it('dedupes repeated repeatable-flag values before persisting (#bug6)', () => { + const spec = flagsToSpec({ name: 'x', coreLanguages: ['rust', 'rust', 'go', 'rust'] }) + expect(spec.selections.coreLanguages).toEqual(['rust', 'go']) + }) +}) + +describe('codex-security findings', () => { + it('rejects credential-bearing (userinfo) scheme git urls (CS#5)', () => { + expect(() => flagsToSpec({ name: 'x', gitUrl: 'https://user:ghp_token@github.com/a/b' })).toThrow(ValidationError) + expect(() => flagsToSpec({ name: 'x', gitUrl: 'https://token@github.com/a/b' })).toThrow(ValidationError) + // A password in ssh:// userinfo is still a secret and stays rejected. + expect(() => flagsToSpec({ name: 'x', gitUrl: 'ssh://git:hunter2@github.com/a/b' })).toThrow(ValidationError) + }) + + it('accepts a bare ssh:// login, consistent with the scp-style form below (CS#5)', () => { + // `ssh://git@host/o/r` and `git@host:o/r` are the same remote written two ways; + // in both, `git@` is an SSH *login*, not a credential. Rejecting only the URL + // form was inconsistent, and blocked the very form the validation error + // recommends ("use SSH ... for private repos"). + expect(flagsToSpec({ name: 'x', gitUrl: 'ssh://git@github.com/a/b' }).gitRepository?.url).toBe( + 'ssh://git@github.com/a/b', + ) + }) + + it('still accepts scp-style ssh remotes (user@ is a login, not a secret) (CS#5)', () => { + const spec = flagsToSpec({ name: 'x', gitUrl: 'git@github.com:org/repo.git' }) + expect(spec.gitRepository?.url).toBe('git@github.com:org/repo.git') + }) +}) + +describe('git remote URL validation', () => { + const accept = (url: string) => flagsToSpec({ name: 'x', gitUrl: url }).gitRepository?.url + const reject = (url: string) => expect(() => flagsToSpec({ name: 'x', gitUrl: url })).toThrow() + + it('accepts the canonical ssh:// form with an SSH login', () => { + // The error message tells users to "use SSH" for private repos, so the + // canonical ssh://git@host/path form must not be rejected. + expect(accept('ssh://git@github.com/foo/bar.git')).toBe('ssh://git@github.com/foo/bar.git') + }) + + it('accepts scp-style and plain https remotes', () => { + expect(accept('git@github.com:foo/bar.git')).toBe('git@github.com:foo/bar.git') + expect(accept('https://github.com/foo/bar.git')).toBe('https://github.com/foo/bar.git') + }) + + it('still rejects embedded credentials in any scheme', () => { + reject('https://user:token@github.com/foo/bar.git') + reject('ssh://user:password@host/foo.git') + reject('https://user@github.com/foo/bar.git') + }) + + it('still rejects shell metacharacters and whitespace', () => { + reject('https://github.com/a/b.git;touch /pwned') + reject('https://github.com/a/b.git $(id)') + reject('ssh://git@host/`id`') + }) +}) + +describe('default hardening posture', () => { + it('applies the development profile when nothing was requested', () => { + // A bare `dcw create` must never produce a completely unhardened environment: + // no capability drops, no no-new-privs and no secure tmpfs, with --strict + // passing vacuously because nothing was asked for. + const spec = flagsToSpec({ name: 'x' }) + expect(spec.profile).toBe('development') + expect(spec.hardening).toContain('no-new-privs') + expect(spec.hardening).toContain('secure-tmp') + expect(spec.hardening.length).toBeGreaterThan(0) + }) + + it('honours an explicit --profile none as a deliberate opt-out', () => { + const spec = flagsToSpec({ name: 'x', profile: 'none' }) + expect(spec.hardening).toEqual([]) + expect(spec.profile).toBe('none') + }) + + it('does not silently add the default on top of explicit --harden keys', () => { + // Naming hardening keys is itself a deliberate choice; merging `development` + // into it would apply controls the user did not ask for. + const spec = flagsToSpec({ name: 'x', hardening: ['drop-caps'] }) + expect(spec.hardening).toEqual(['drop-caps']) + expect(spec.profile).toBeUndefined() + }) + + it('leaves an explicit profile alone', () => { + const spec = flagsToSpec({ name: 'x', profile: 'paranoid' }) + expect(spec.profile).toBe('paranoid') + expect(spec.hardening).toContain('readonly-os') + }) + + it('still rejects an unknown profile', () => { + expect(() => flagsToSpec({ name: 'x', profile: 'bogus' })).toThrow(ValidationError) + }) +}) diff --git a/packages/core/test/unit/guard.test.ts b/packages/core/test/unit/guard.test.ts new file mode 100644 index 0000000..4a4f815 --- /dev/null +++ b/packages/core/test/unit/guard.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { guardToolSnippet, parseInstructions, REPORT_PATH } from '../../src/containerfile/guard.js' +import { parseToolReport } from '../../src/core/build-pipeline.js' +import { generateContainerfile } from '../../src/containerfile/generate.js' + +describe('parseInstructions', () => { + it('groups RUN continuations and separates ENV', () => { + const snippet = 'RUN a && \\\n b\nENV X=1\nRUN c' + const inst = parseInstructions(snippet) + expect(inst.map((i) => i.isRun)).toEqual([true, false, true]) + expect(inst[0]!.lines).toHaveLength(2) + }) +}) + +describe('guardToolSnippet', () => { + it('wraps each RUN with a success/failure recorder and leaves ENV alone', () => { + const out = guardToolSnippet('heimdall', 'RUN install-it\nENV PATH=/x:$PATH') + expect(out).toContain('RUN ( install-it ) &&') + expect(out).toContain(`heimdall=ok" >> ${REPORT_PATH}`) + expect(out).toContain(`heimdall=fail" >> ${REPORT_PATH}`) + expect(out).toContain('ENV PATH=/x:$PATH') + }) + + it('keeps multi-line RUN bodies intact inside the subshell', () => { + const out = guardToolSnippet('medusa', 'RUN one && \\\n two') + expect(out).toContain('RUN ( one && \\') + expect(out).toContain(' two ) &&') + }) +}) + +describe('parseToolReport', () => { + it('marks a tool failed if any line says fail', () => { + const report = parseToolReport('rust=ok\nmedusa=ok\nmedusa=fail\nslither=ok') + expect(report).toEqual([ + { name: 'medusa', ok: false }, + { name: 'rust', ok: true }, + { name: 'slither', ok: true }, + ]) + }) +}) + +describe('generateContainerfile (best-effort + shim)', () => { + it('keeps runtimes fail-fast but guards leaf tools', () => { + const cf = generateContainerfile({ selections: { coreLanguages: ['rust'], frameworks: ['foundry'] } }) + // runtime (rust) is NOT guarded + expect(cf).toContain('RUN curl --proto') + expect(cf).not.toContain('rust=ok') + // leaf tool (foundry) IS guarded + expect(cf).toContain('foundry=ok') + expect(cf).toContain('foundry=fail') + }) + + it('injects the libssl1.1 shim before ityfuzz', () => { + const cf = generateContainerfile({ selections: { fuzzingAndTesting: ['ityfuzz'] } }) + expect(cf).toContain('libssl1.1_1.1.1w') + expect(cf.indexOf('libssl1.1_1.1.1w')).toBeLessThan(cf.indexOf('ity.fuzz.land')) + // shim is also guarded under the ityfuzz tool + expect(cf).toContain('ityfuzz=fail') + }) + + it('prints the report only when leaf tools exist', () => { + expect(generateContainerfile({ selections: { coreLanguages: ['rust'] } })).not.toContain('tool install report') + expect(generateContainerfile({ selections: { frameworks: ['foundry'] } })).toContain('tool install report') + }) +}) + +describe('assertCloneSafe — option-injection defense in depth', () => { + it('rejects a leading-dash value that git would read as an option', () => { + // `git clone --upload-pack=... ` is a build-time RCE primitive. The zod + // schema blocks this at the CLI boundary, but this guard bills itself as the + // last line of defense before the value is spliced into an unquoted RUN line. + expect(() => + generateContainerfile({ + selections: {}, + gitRepository: { url: '--upload-pack=touch/pwned', enabled: true }, + }), + ).toThrow(/unsafe/i) + }) + + it('rejects a leading-dash branch too', () => { + expect(() => + generateContainerfile({ + selections: {}, + gitRepository: { url: 'https://github.com/a/b.git', branch: '--upload-pack=x', enabled: true }, + }), + ).toThrow(/unsafe/i) + }) + + it('still accepts a normal repo + branch', () => { + const out = generateContainerfile({ + selections: {}, + gitRepository: { url: 'https://github.com/a/b.git', branch: 'main', enabled: true }, + }) + expect(out).toContain('git clone --branch main https://github.com/a/b.git') + }) +}) diff --git a/packages/core/test/unit/hardening-report.test.ts b/packages/core/test/unit/hardening-report.test.ts new file mode 100644 index 0000000..25a620c --- /dev/null +++ b/packages/core/test/unit/hardening-report.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { planEnvironment } from '../../src/core/plan.js' +import { AppleContainerDriver } from '../../src/engine/drivers/apple-container.js' +import { caps } from '../../src/engine/drivers/capabilities.js' +import { hardeningReport, translate } from '../../src/hardening/translator.js' +import { EnvSpecSchema } from '../../src/spec/env-spec.js' + +function reportFor(hardening: string[], engine: 'docker' | 'apple-container') { + const spec = EnvSpecSchema.parse({ name: 'demo', hardening }) + const plan = planEnvironment(spec) + const capabilities = engine === 'docker' ? caps() : new AppleContainerDriver().capabilities + const t = translate(plan.effects, capabilities, engine) + return hardeningReport(t, t.flags) +} + +describe('hardeningReport — the --json hardening contract', () => { + it('names every control the engine could not honor, so agents can see it', () => { + // The airgapped promise is the whole point of the profile; if an engine cannot + // enforce it, `--json` consumers must be able to detect that from the envelope + // alone, since this.warn() output never reaches them. + const report = reportFor(['network-none'], 'apple-container') + expect(report.dropped).toContain('network-none') + expect(report.warnings.some((w) => w.level === 'dropped')).toBe(true) + expect(report.appliedFlags).not.toContain('--network=none') + }) + + it('reports an air-gap that IS enforced as applied and not dropped', () => { + const report = reportFor(['network-none'], 'docker') + expect(report.dropped).not.toContain('network-none') + expect(report.appliedFlags).toContain('--network=none') + }) + + it('exposes appliedFlags, warnings, dropped and unenforced on every report', () => { + const report = reportFor(['drop-caps'], 'docker') + expect(Object.keys(report).sort()).toEqual(['appliedFlags', 'dropped', 'unenforced', 'warnings']) + expect(Array.isArray(report.unenforced)).toBe(true) + }) +}) diff --git a/packages/core/test/unit/install-commands.test.ts b/packages/core/test/unit/install-commands.test.ts new file mode 100644 index 0000000..358f230 --- /dev/null +++ b/packages/core/test/unit/install-commands.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { INSTALL_COMMANDS } from '../../src/domain/install-commands.js' + +describe('install snippets', () => { + it('medusa emits a correct git-describe suffix-stripping sed (#11)', () => { + // The non-raw template previously collapsed \+ and \w, breaking the regex. + expect(INSTALL_COMMANDS.medusa).toContain("sed 's/-[0-9]\\+-g\\w\\+$//'") + // …and uses real backslash-newline line continuations (not collapsed away). + expect(INSTALL_COMMANDS.medusa).toMatch(/&& \\\n/) + }) + + it('heimdall/echidna do not self-mask install failures (#13)', () => { + expect(INSTALL_COMMANDS.heimdall).not.toContain("|| echo 'Heimdall installed'") + expect(INSTALL_COMMANDS.echidna).not.toContain('|| echo') + }) + + it('aderyn keeps its legitimate command fallback (#13)', () => { + expect(INSTALL_COMMANDS.aderyn).toContain('|| cyfrinup') + }) +}) diff --git a/packages/core/test/unit/json-error-envelope.test.ts b/packages/core/test/unit/json-error-envelope.test.ts new file mode 100644 index 0000000..3c2126c --- /dev/null +++ b/packages/core/test/unit/json-error-envelope.test.ts @@ -0,0 +1,59 @@ +import { execFile } from 'node:child_process' +import * as path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') +const dev = path.join(pkgRoot, 'bin', 'dev.js') + +/** Run the CLI and capture stdout/stderr/exit code without throwing on failure. */ +function run(args: string[]): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise((resolve) => { + execFile( + process.execPath, + ['--import', 'tsx', dev, ...args], + { env: { ...process.env, NODE_NO_WARNINGS: '1' }, maxBuffer: 64 * 1024 * 1024 }, + (err, stdout, stderr) => { + const code = err && typeof (err as { code?: unknown }).code === 'number' ? (err as { code: number }).code : 0 + resolve({ code, stdout, stderr }) + }, + ) + }) +} + +describe('--json error envelope for usage errors', () => { + it('emits a compact {error:{code,message}} envelope for an unknown flag', async () => { + const { code, stdout } = await run(['ls', '--json', '--definitely-not-a-flag']) + + const parsed = JSON.parse(stdout) as { error: { code: string; message: string } } + expect(typeof parsed.error.code).toBe('string') + expect(typeof parsed.error.message).toBe('string') + expect(parsed.error.message).toMatch(/definitely-not-a-flag/) + // Usage errors must use the documented UsageError code, not a generic 1. + expect(code).toBe(2) + }) + + it('does not leak oclif internals (config, home dir, plugin list) into stdout', async () => { + const { stdout } = await run(['ls', '--json', '--definitely-not-a-flag']) + // The raw oclif error object serialized to ~120 kB of internal state. + expect(stdout.length).toBeLessThan(4096) + expect(stdout).not.toContain('userAgent') + expect(stdout).not.toContain('plugins') + expect(stdout).not.toContain('shell') + }) + + it('uses the same envelope for an invalid --engine value', async () => { + const { code, stdout } = await run(['ls', '--json', '--engine', 'bogus-engine']) + const parsed = JSON.parse(stdout) as { error: { code: string; message: string } } + expect(parsed.error.code).toBeTruthy() + expect(parsed.error.message).toMatch(/bogus-engine/) + expect(code).toBe(2) + }) + + it('keeps stdout free of the envelope when --json is absent', async () => { + const { code, stdout, stderr } = await run(['ls', '--definitely-not-a-flag']) + expect(stdout).not.toContain('"error"') + expect(stderr.length).toBeGreaterThan(0) + expect(code).toBe(2) + }) +}) diff --git a/packages/core/test/unit/json-flag.test.ts b/packages/core/test/unit/json-flag.test.ts new file mode 100644 index 0000000..751d03b --- /dev/null +++ b/packages/core/test/unit/json-flag.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import Exec from '../../src/commands/exec.js' +import Shell from '../../src/commands/shell.js' +import Logs from '../../src/commands/logs.js' +import Agent from '../../src/commands/agent.js' +import Create from '../../src/commands/create.js' + +describe('streaming commands disable --json (#3)', () => { + it('exec/shell/logs/agent are pass-through (no JSON envelope to corrupt exit codes)', () => { + for (const Cmd of [Exec, Shell, Logs, Agent]) { + expect(Cmd.enableJsonFlag, Cmd.name).toBe(false) + } + }) + + it('but still accept --json as a no-op so agents do not hard-error (#N7)', () => { + for (const Cmd of [Exec, Shell, Logs, Agent]) { + expect(Cmd.flags?.json, Cmd.name).toBeDefined() + } + }) + + it('create still supports --json', () => { + expect(Create.enableJsonFlag).toBe(true) + }) +}) diff --git a/packages/core/test/unit/logs-tail.test.ts b/packages/core/test/unit/logs-tail.test.ts new file mode 100644 index 0000000..ae4d068 --- /dev/null +++ b/packages/core/test/unit/logs-tail.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest' +import Logs from '../../src/commands/logs.js' + +describe('logs --tail bounds (#bug5)', () => { + it('declares a lower bound so a negative value is rejected by oclif, not forwarded to the engine', () => { + // The integer flag must carry min:0 so `--tail -5` fails cleanly at parse time + // instead of being passed through to docker/podman `logs --tail -5`. + const tail = Logs.flags.tail as { min?: number } + expect(tail.min).toBe(0) + }) +}) diff --git a/packages/core/test/unit/ls-reconcile.test.ts b/packages/core/test/unit/ls-reconcile.test.ts new file mode 100644 index 0000000..acfbaf8 --- /dev/null +++ b/packages/core/test/unit/ls-reconcile.test.ts @@ -0,0 +1,66 @@ +import { execFile } from 'node:child_process' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') +const dev = path.join(pkgRoot, 'bin', 'dev.js') + +let tmp: string +let baseEnv: NodeJS.ProcessEnv + +beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dcw-ls-')) + baseEnv = { + ...process.env, + XDG_CONFIG_HOME: path.join(tmp, 'config'), + XDG_STATE_HOME: path.join(tmp, 'state'), + NODE_NO_WARNINGS: '1', + } +}) + +afterEach(async () => { + await fs.rm(tmp, { recursive: true, force: true }) +}) + +function run(args: string[], env: NodeJS.ProcessEnv): Promise<{ code: number; stdout: string }> { + return new Promise((resolve) => { + execFile(process.execPath, ['--import', 'tsx', dev, ...args], { env }, (err, stdout) => { + const code = err && typeof (err as { code?: unknown }).code === 'number' ? (err as { code: number }).code : 0 + resolve({ code, stdout }) + }) + }) +} + +describe('dcw ls status reconciliation', () => { + it("reports 'unknown', not 'absent', when the engine cannot be reached", async () => { + await run(['create', '--no-input', '--name', 'ghost'], baseEnv) + + // Mark the env as having had a container, then make the engine unreachable. + const file = path.join(tmp, 'config', 'dcw', 'environments', 'ghost.json') + const m = JSON.parse(await fs.readFile(file, 'utf8')) + m.engine = 'docker' + m.image = { tag: 'dcw/ghost:latest', imageId: 'sha256:x', containerfileHash: 'h', builtAt: m.createdAt } + m.container = { id: 'cid', name: 'dcw-ghost', status: 'running', startedAt: m.createdAt, appliedFlags: [], droppedHardening: [] } + await fs.writeFile(file, JSON.stringify(m, null, 2)) + + // Empty PATH (bar node itself) => no container engine binary is discoverable. + const noEngine = { ...baseEnv, PATH: path.dirname(process.execPath) } + const { stdout } = await run(['ls', '--json'], noEngine) + const parsed = JSON.parse(stdout) as { environments: Array<{ name: string; status: string }> } + const ghost = parsed.environments.find((e) => e.name === 'ghost')! + + // 'absent' would assert the container is gone; we simply cannot tell. + expect(ghost.status).toBe('unknown') + }) + + it("still reports 'never-started' for an env that was never brought up", async () => { + await run(['create', '--no-input', '--name', 'fresh'], baseEnv) + const noEngine = { ...baseEnv, PATH: path.dirname(process.execPath) } + const { stdout } = await run(['ls', '--json'], noEngine) + const parsed = JSON.parse(stdout) as { environments: Array<{ name: string; status: string }> } + expect(parsed.environments.find((e) => e.name === 'fresh')?.status).toBe('never-started') + }) +}) diff --git a/packages/core/test/unit/manifest-migration.test.ts b/packages/core/test/unit/manifest-migration.test.ts new file mode 100644 index 0000000..55324d9 --- /dev/null +++ b/packages/core/test/unit/manifest-migration.test.ts @@ -0,0 +1,86 @@ +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { listManifests, loadManifest, saveManifest } from '../../src/state/store.js' +import { manifestPath } from '../../src/state/paths.js' +import { SCHEMA_VERSION, type EnvManifest } from '../../src/state/manifest.js' +import { ValidationError } from '../../src/errors.js' + +let tmp: string + +beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dcw-migrate-')) + process.env.XDG_CONFIG_HOME = path.join(tmp, 'config') + process.env.XDG_STATE_HOME = path.join(tmp, 'state') +}) + +afterEach(async () => { + delete process.env.XDG_CONFIG_HOME + delete process.env.XDG_STATE_HOME + await fs.rm(tmp, { recursive: true, force: true }) +}) + +function fixture(name: string): EnvManifest { + return { + schemaVersion: SCHEMA_VERSION, + name, + createdAt: '2026-06-11T00:00:00.000Z', + updatedAt: '2026-06-11T00:00:00.000Z', + spec: { name, engine: 'auto', selections: { frameworks: ['foundry'] }, hardening: ['drop-caps'], ssh: true }, + resolved: { requiredTools: ['rust', 'foundry'], hardeningKeys: ['drop-caps'] }, + engine: null, + image: null, + container: null, + } +} + +async function writeRaw(name: string, obj: unknown): Promise { + const p = manifestPath(name) + await fs.mkdir(path.dirname(p), { recursive: true }) + await fs.writeFile(p, JSON.stringify(obj, null, 2)) +} + +describe('manifest schema migration (#bug3)', () => { + it('loads a manifest at the current schemaVersion', async () => { + await saveManifest(fixture('alpha')) + expect((await loadManifest('alpha'))?.name).toBe('alpha') + }) + + it('rejects a manifest written by a newer dcw with a friendly ValidationError', async () => { + await writeRaw('future', { ...fixture('future'), schemaVersion: SCHEMA_VERSION + 1 }) + await expect(loadManifest('future')).rejects.toBeInstanceOf(ValidationError) + await expect(loadManifest('future')).rejects.toThrow(/newer dcw/i) + }) + + it('rejects a manifest missing a usable schemaVersion', async () => { + const { schemaVersion: _omit, ...rest } = fixture('noversion') + void _omit + await writeRaw('noversion', rest) + await expect(loadManifest('noversion')).rejects.toBeInstanceOf(ValidationError) + }) + + it('still rejects field-level garbage in the strict spec (version tolerance != field tolerance)', async () => { + const m = fixture('garbage') + await writeRaw('garbage', { ...m, spec: { ...m.spec, bogusField: true } }) + await expect(loadManifest('garbage')).rejects.toBeInstanceOf(ValidationError) + }) + + it('skips a newer-version manifest in listings without crashing', async () => { + await saveManifest(fixture('ok')) + await writeRaw('newer', { ...fixture('newer'), schemaVersion: SCHEMA_VERSION + 1 }) + const names = (await listManifests()).map((m) => m.name) + expect(names).toEqual(['ok']) + }) +}) + +describe('listManifests filename/name integrity (#bug7)', () => { + it('skips a manifest whose internal name does not match its filename', async () => { + await saveManifest(fixture('real')) + // A file named mismatch.json whose internal name is "other" is unreachable by + // command name → it must not appear in `ls`. + await writeRaw('mismatch', { ...fixture('other') }) + const names = (await listManifests()).map((m) => m.name) + expect(names).toEqual(['real']) + }) +}) diff --git a/packages/core/test/unit/pipelines.test.ts b/packages/core/test/unit/pipelines.test.ts new file mode 100644 index 0000000..04aa16e --- /dev/null +++ b/packages/core/test/unit/pipelines.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest' +import { planEnvironment } from '../../src/core/plan.js' +import { buildEnvironment, imageTag } from '../../src/core/build-pipeline.js' +import { ENV_LABEL, upEnvironment } from '../../src/core/up-pipeline.js' +import { createDriver } from '../../src/engine/registry.js' +import { StrictHardeningError } from '../../src/errors.js' +import { EnvSpecSchema, type EnvSpec } from '../../src/spec/env-spec.js' +import { SCHEMA_VERSION, type EnvManifest } from '../../src/state/manifest.js' +import { FakeDriver } from '../engine/fake-driver.js' + +const NOW = '2026-06-11T00:00:00.000Z' + +function spec(overrides: Partial = {}): EnvSpec { + return EnvSpecSchema.parse({ name: 'demo', selections: { frameworks: ['foundry'] }, hardening: ['drop-caps'], ...overrides }) +} + +function manifestFor(s: EnvSpec): EnvManifest { + return { + schemaVersion: SCHEMA_VERSION, + name: s.name, + createdAt: NOW, + updatedAt: NOW, + spec: s, + resolved: { requiredTools: [], hardeningKeys: s.hardening }, + engine: null, + image: null, + container: null, + } +} + +describe('planEnvironment', () => { + it('produces a containerfile, effects, and a stable hash', () => { + const plan = planEnvironment(spec()) + expect(plan.containerfile).toContain('FROM debian:trixie') + expect(plan.effects.some((e) => e.kind === 'drop-cap')).toBe(true) + expect(plan.containerfileHash).toMatch(/^[a-f0-9]{64}$/) + }) +}) + +describe('buildEnvironment', () => { + it('builds and records image state', async () => { + const s = spec() + const driver = new FakeDriver({ name: 'docker' }) + const plan = planEnvironment(s) + const out = await buildEnvironment({ manifest: manifestFor(s), plan, driver, engineName: 'docker', now: NOW }) + + expect(driver.builds).toHaveLength(1) + expect(driver.builds[0]!.tag).toBe(imageTag('demo')) + expect(out.manifest.image?.containerfileHash).toBe(plan.containerfileHash) + expect(out.manifest.engine).toBe('docker') + expect(out.skipped).toBe(false) + }) + + it('skips the build when image is up-to-date', async () => { + const s = spec() + const driver = new FakeDriver({ name: 'docker' }) + const plan = planEnvironment(s) + const first = await buildEnvironment({ manifest: manifestFor(s), plan, driver, engineName: 'docker', now: NOW }) + const second = await buildEnvironment({ manifest: first.manifest, plan, driver, engineName: 'docker', now: NOW }) + expect(second.skipped).toBe(true) + expect(driver.builds).toHaveLength(1) + }) + + it('rebuilds with force', async () => { + const s = spec() + const driver = new FakeDriver({ name: 'docker' }) + const plan = planEnvironment(s) + const first = await buildEnvironment({ manifest: manifestFor(s), plan, driver, engineName: 'docker', now: NOW }) + await buildEnvironment({ manifest: first.manifest, plan, driver, engineName: 'docker', now: NOW, force: true }) + expect(driver.builds).toHaveLength(2) + }) + + it('hardens the report probe and marks tools verified on Docker (#N4)', async () => { + const s = spec() + const driver = new FakeDriver({ name: 'docker' }) + const plan = planEnvironment(s) + expect(plan.tools.tools.length).toBeGreaterThan(0) // probe only runs with leaf tools + const out = await buildEnvironment({ manifest: manifestFor(s), plan, driver, engineName: 'docker', now: NOW }) + expect(driver.runOnces).toHaveLength(1) + expect(driver.runOnces[0]!.flags).toEqual(['--network=none', '--cap-drop=ALL']) + expect(out.toolsVerified).toBe(true) + }) + + it('marks tools unverified (not falsely clean) when the report is unreadable (#N4)', async () => { + const s = spec() + const driver = new FakeDriver({ name: 'docker', runOnceResult: { stdout: '', code: 1 } }) + const plan = planEnvironment(s) + const out = await buildEnvironment({ manifest: manifestFor(s), plan, driver, engineName: 'docker', now: NOW }) + expect(out.toolsVerified).toBe(false) + expect(out.tools).toEqual([]) + }) + + it('omits unsupported probe flags on non-Docker engines (#N4)', async () => { + const s = spec() + const driver = new FakeDriver({ name: 'apple-container', capabilities: createDriver('apple-container').capabilities }) + const plan = planEnvironment(s) + await buildEnvironment({ manifest: manifestFor(s), plan, driver, engineName: 'apple-container', now: NOW }) + // apple-container has no --network=none, but it does enforce --cap-drop, so the + // throwaway `cat` probe should still run with capabilities dropped. + expect(driver.runOnces[0]!.flags).toEqual(['--cap-drop=ALL']) + }) +}) + +describe('upEnvironment', () => { + async function built(s: EnvSpec, driver: FakeDriver) { + const plan = planEnvironment(s) + const b = await buildEnvironment({ manifest: manifestFor(s), plan, driver, engineName: driver.name, now: NOW }) + return { plan, manifest: b.manifest } + } + + it('runs detached with hardening flags + bind workspace + label', async () => { + const s = spec() + const driver = new FakeDriver({ name: 'docker' }) + const { plan, manifest } = await built(s, driver) + const out = await upEnvironment({ + manifest, + plan, + driver, + capabilities: driver.capabilities, + engineName: 'docker', + workspaceDir: '/home/me/project', + now: NOW, + }) + + const run = driver.runs[0]! + expect(run.detach).toBe(true) + expect(run.command).toEqual(['sleep', 'infinity']) + expect(run.labels).toEqual({ [ENV_LABEL]: 'demo' }) + expect(run.flags).toContain('--cap-drop=ALL') + expect(run.flags).toContain('-v') + expect(run.flags).toContain('/home/me/project:/workspace') + expect(out.manifest.container?.status).toBe('running') + }) + + it('uses a tmpfs workspace (no bind) for ephemeral environments', async () => { + const s = spec({ hardening: ['ephemeral-workspace'] }) + const driver = new FakeDriver({ name: 'docker' }) + const { plan, manifest } = await built(s, driver) + await upEnvironment({ + manifest, + plan, + driver, + capabilities: driver.capabilities, + engineName: 'docker', + workspaceDir: '/home/me/project', + now: NOW, + }) + const run = driver.runs[0]! + expect(run.flags).not.toContain('-v') + expect(run.flags.join(' ')).toContain('/workspace:rw') + }) + + it('records dropped hardening on the manifest for degraded engines', async () => { + const s = spec({ hardening: ['drop-caps', 'apparmor', 'network-none'] }) + const driver = new FakeDriver({ name: 'apple-container', capabilities: createDriver('apple-container').capabilities }) + const { plan, manifest } = await built(s, driver) + const out = await upEnvironment({ + manifest, + plan, + driver, + capabilities: driver.capabilities, + engineName: 'apple-container', + workspaceDir: '/tmp/x', + now: NOW, + }) + // drop-caps IS honored by the container CLI, so only the genuinely + // unavailable controls are recorded as dropped. + expect(out.manifest.container?.droppedHardening).toEqual(expect.arrayContaining(['apparmor', 'network-none'])) + expect(out.manifest.container?.droppedHardening).not.toContain('drop-cap') + expect(out.translation.dropped.length).toBeGreaterThan(0) + }) + + it('throws under --strict when hardening is dropped', async () => { + const s = spec({ hardening: ['apparmor'] }) + const driver = new FakeDriver({ name: 'apple-container', capabilities: createDriver('apple-container').capabilities }) + const { plan, manifest } = await built(s, driver) + await expect( + upEnvironment({ + manifest, + plan, + driver, + capabilities: driver.capabilities, + engineName: 'apple-container', + workspaceDir: '/tmp/x', + now: NOW, + strict: true, + }), + ).rejects.toBeInstanceOf(StrictHardeningError) + }) +}) diff --git a/packages/core/test/unit/profiles.test.ts b/packages/core/test/unit/profiles.test.ts new file mode 100644 index 0000000..ff62d94 --- /dev/null +++ b/packages/core/test/unit/profiles.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { PROFILES, recipesToHardening } from '../../src/domain/profiles.js' +import { normalizeHardening } from '../../src/domain/normalize.js' + +describe('recipesToHardening', () => { + it('expands development profile', () => { + const keys = recipesToHardening(['development']) + expect(keys).toEqual(expect.arrayContaining(['secure-tmp', 'no-new-privs', 'apparmor', 'secure-dns', 'vscode-security'])) + }) + + it('airgapped includes network-none', () => { + expect(recipesToHardening(['airgapped'])).toContain('network-none') + }) + + it('paranoid includes readonly-os and network-none', () => { + const keys = recipesToHardening(['paranoid']) + expect(keys).toEqual(expect.arrayContaining(['readonly-os', 'network-none'])) + }) + + it('unions and dedupes across multiple profiles', () => { + const keys = recipesToHardening(['development', 'hardened']) + expect(new Set(keys).size).toBe(keys.length) + expect(keys).toContain('drop-caps') + }) + + it('every profile expands only to canonical hardening keys', () => { + for (const profile of PROFILES) { + const { unknown } = normalizeHardening(profile.choices) + expect(unknown, `profile ${profile.key} has unknown keys`).toEqual([]) + } + }) + + it('ignores unknown profile keys', () => { + expect(recipesToHardening(['nonexistent'])).toEqual([]) + }) +}) + +describe('normalizeHardening', () => { + it('maps legacy resource-limits aliases to canonical keys', () => { + expect(normalizeHardening(['resource-limits']).keys).toEqual(['resource-limits-light']) + expect(normalizeHardening(['resource-limits-medium']).keys).toEqual(['resource-limits-standard']) + }) + + it('collapses conflicting resource tiers to the strongest', () => { + const { keys } = normalizeHardening(['resource-limits-light', 'resource-limits-heavy']) + expect(keys).toEqual(['resource-limits-heavy']) + }) + + it('reports unknown keys', () => { + const { keys, unknown } = normalizeHardening(['drop-caps', 'bogus']) + expect(keys).toEqual(['drop-caps']) + expect(unknown).toEqual(['bogus']) + }) + + it('dedupes and returns canonical order', () => { + const { keys } = normalizeHardening(['secure-dns', 'drop-caps', 'drop-caps']) + // canonical catalog order: drop-caps comes before secure-dns + expect(keys).toEqual(['drop-caps', 'secure-dns']) + }) +}) diff --git a/packages/core/test/unit/rm-no-engine.test.ts b/packages/core/test/unit/rm-no-engine.test.ts new file mode 100644 index 0000000..195282c --- /dev/null +++ b/packages/core/test/unit/rm-no-engine.test.ts @@ -0,0 +1,60 @@ +import { execFile } from 'node:child_process' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') +const dev = path.join(pkgRoot, 'bin', 'dev.js') + +let tmp: string +let env: NodeJS.ProcessEnv + +beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dcw-rm-')) + // An empty PATH (plus node's own dir so tsx still runs) means no container + // engine binary is discoverable — the "Docker was uninstalled" scenario. + env = { + ...process.env, + XDG_CONFIG_HOME: path.join(tmp, 'config'), + XDG_STATE_HOME: path.join(tmp, 'state'), + PATH: path.dirname(process.execPath), + NODE_NO_WARNINGS: '1', + } +}) + +afterEach(async () => { + await fs.rm(tmp, { recursive: true, force: true }) +}) + +function run(args: string[]): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise((resolve) => { + execFile(process.execPath, ['--import', 'tsx', dev, ...args], { env }, (err, stdout, stderr) => { + const code = err && typeof (err as { code?: unknown }).code === 'number' ? (err as { code: number }).code : 0 + resolve({ code, stdout, stderr }) + }) + }) +} + +describe('dcw rm --purge without a usable container engine', () => { + it('still purges local state instead of stranding the environment', async () => { + const created = await run(['create', '--no-input', '--name', 'orphan']) + expect(created.code).toBe(0) + + const manifest = path.join(tmp, 'config', 'dcw', 'environments', 'orphan.json') + expect(await fs.stat(manifest)).toBeTruthy() + + // Without the fix this fails with E_NO_ENGINE (exit 3) and the manifest stays + // on disk forever: `ls` shows it, and nothing can delete it. + const removed = await run(['rm', 'orphan', '--purge', '--yes']) + expect(removed.code).toBe(0) + await expect(fs.stat(manifest)).rejects.toThrow() + }) + + it('still refuses a non-purge rm when the engine is unavailable', async () => { + await run(['create', '--no-input', '--name', 'keeper']) + const removed = await run(['rm', 'keeper']) + expect(removed.code).toBe(3) + }) +}) diff --git a/packages/core/test/unit/schema-doc.test.ts b/packages/core/test/unit/schema-doc.test.ts new file mode 100644 index 0000000..3995849 --- /dev/null +++ b/packages/core/test/unit/schema-doc.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { buildSchemaDoc } from '../../src/spec/schema-doc.js' +import { CATALOG } from '../../src/domain/catalog.js' + +describe('buildSchemaDoc', () => { + const doc = buildSchemaDoc('9.9.9') + + it('includes the EnvSpec JSON schema', () => { + expect(doc.version).toBe('9.9.9') + const json = doc.envSpec as { $ref?: string; definitions?: Record } + expect(json.definitions?.EnvSpec).toBeDefined() + }) + + it('exposes every catalog category and its options', () => { + expect(doc.catalog).toHaveLength(CATALOG.length) + const core = doc.catalog.find((c) => c.key === 'coreLanguages')! + expect(core.options.map((o) => o.value)).toEqual(['rust', 'python', 'go', 'node']) + }) + + it('lists profiles, hardening options, and engines', () => { + expect(doc.profiles.some((p) => p.key === 'hardened')).toBe(true) + expect(doc.hardening.some((h) => h.key === 'drop-caps')).toBe(true) + expect(doc.engines).toContain('apple-container') + }) +}) diff --git a/packages/core/test/unit/skill.test.ts b/packages/core/test/unit/skill.test.ts new file mode 100644 index 0000000..dece24d --- /dev/null +++ b/packages/core/test/unit/skill.test.ts @@ -0,0 +1,45 @@ +import { execFile } from 'node:child_process' +import * as path from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { describe, expect, it } from 'vitest' +import Skill from '../../src/commands/skill.js' +import { readSkill, SKILL_PATH, skillName } from '../../src/skill.js' +import { skillArgv } from '../../bin/skill-flag.js' + +const run = promisify(execFile) +const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') +const dev = path.join(pkgRoot, 'bin', 'dev.js') + +describe('packaged agent skill', () => { + it('resolves to skill/SKILL.md with valid frontmatter named dcw', () => { + expect(SKILL_PATH.endsWith(path.join('skill', 'SKILL.md'))).toBe(true) + const md = readSkill() + expect(md.startsWith('---\nname: dcw\n')).toBe(true) + expect(md).toMatch(/^description:/m) + expect(skillName(md)).toBe('dcw') + }) + + it('documents how to (re)install itself via `dcw --skill`', () => { + expect(readSkill()).toContain('dcw --skill >') + }) + + it('`skill` command is a raw pass-through (no JSON envelope) but tolerates --json', () => { + expect(Skill.enableJsonFlag).toBe(false) + expect(Skill.flags?.json).toBeDefined() + }) +}) + +describe('top-level --skill alias', () => { + it('rewrites only a leading --skill into the skill command', () => { + expect(skillArgv(['--skill'])).toEqual(['skill']) + expect(skillArgv(['--skill', '--json'])).toEqual(['skill', '--json']) + expect(skillArgv(['create', '--skill'])).toEqual(['create', '--skill']) + expect(skillArgv([])).toEqual([]) + }) + + it('`dcw --skill` prints the skill file verbatim and exits 0', async () => { + const { stdout } = await run('node', ['--import', 'tsx', dev, '--skill'], { cwd: pkgRoot }) + expect(stdout).toBe(readSkill()) + }, 30_000) +}) diff --git a/packages/core/test/unit/ssh-config.test.ts b/packages/core/test/unit/ssh-config.test.ts new file mode 100644 index 0000000..a33ee14 --- /dev/null +++ b/packages/core/test/unit/ssh-config.test.ts @@ -0,0 +1,96 @@ +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + hostAlias, + removeSshConfig, + renderEntry, + writeSshConfig, + type SshConfigEntry, +} from '../../src/core/ssh/ssh-config.js' + +let tmp: string +let realHome: string | undefined + +beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dcw-ssh-')) + realHome = process.env.HOME + process.env.HOME = tmp +}) + +afterEach(async () => { + if (realHome === undefined) delete process.env.HOME + else process.env.HOME = realHome + await fs.rm(tmp, { recursive: true, force: true }) +}) + +function execEntry(name: string): SshConfigEntry { + return { + name, + mode: 'exec', + identityFile: '/keys/id_ed25519', + knownHostsFile: '/keys/known_hosts', + proxyCommand: `dcw ssh-proxy ${name}`, + } +} + +async function readConfig(): Promise { + return fs.readFile(path.join(tmp, '.ssh', 'config'), 'utf8') +} + +describe('ssh-config', () => { + it('renders an exec entry with a ProxyCommand and no port', () => { + const body = renderEntry(execEntry('alpha')) + expect(body).toContain('Host dcw-alpha') + expect(body).toContain('ProxyCommand dcw ssh-proxy alpha') + expect(body).toContain('User vscode') + expect(body).not.toContain('HostName') + expect(body).not.toContain('Port') + }) + + it('renders a port entry with HostName/Port and no ProxyCommand', () => { + const body = renderEntry({ + name: 'beta', + mode: 'port', + identityFile: '/k/id', + knownHostsFile: '/k/kh', + port: 2222, + }) + expect(body).toContain('HostName localhost') + expect(body).toContain('Port 2222') + expect(body).not.toContain('ProxyCommand') + }) + + it('is idempotent: writing the same entry twice yields one managed block', async () => { + const alias = await writeSshConfig(execEntry('alpha')) + expect(alias).toBe('dcw-alpha') + await writeSshConfig(execEntry('alpha')) + const content = await readConfig() + const begins = content.match(/# >>> dcw alpha >>>/g) ?? [] + expect(begins).toHaveLength(1) + }) + + it('preserves unrelated host blocks and other dcw envs', async () => { + const file = path.join(tmp, '.ssh', 'config') + await fs.mkdir(path.dirname(file), { recursive: true }) + await fs.writeFile(file, 'Host github.com\n User git\n') + + await writeSshConfig(execEntry('alpha')) + await writeSshConfig(execEntry('beta')) + let content = await readConfig() + expect(content).toContain('Host github.com') + expect(content).toContain('Host dcw-alpha') + expect(content).toContain('Host dcw-beta') + + await removeSshConfig('alpha') + content = await readConfig() + expect(content).toContain('Host github.com') + expect(content).not.toContain('Host dcw-alpha') + expect(content).toContain('Host dcw-beta') + }) + + it('hostAlias is dcw-prefixed', () => { + expect(hostAlias('my-env')).toBe('dcw-my-env') + }) +}) diff --git a/packages/core/test/unit/ssh-keys.test.ts b/packages/core/test/unit/ssh-keys.test.ts new file mode 100644 index 0000000..3eea081 --- /dev/null +++ b/packages/core/test/unit/ssh-keys.test.ts @@ -0,0 +1,33 @@ +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { ensureKeypair, knownHostsPath } from '../../src/core/ssh/keys.js' + +let tmp: string + +beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dcw-keys-')) + process.env.XDG_CONFIG_HOME = path.join(tmp, 'config') +}) + +afterEach(async () => { + delete process.env.XDG_CONFIG_HOME + await fs.rm(tmp, { recursive: true, force: true }) +}) + +describe('ssh keys', () => { + it('generates an ed25519 keypair once and reuses it', async () => { + const first = await ensureKeypair() + expect(first.publicKey).toMatch(/^ssh-ed25519 /) + await fs.access(first.privateKeyPath) + await fs.access(first.publicKeyPath) + + const second = await ensureKeypair() + expect(second.publicKey).toBe(first.publicKey) + }) + + it('places known_hosts alongside the keypair under the dcw config dir', () => { + expect(knownHostsPath()).toBe(path.join(tmp, 'config', 'dcw', 'ssh', 'known_hosts')) + }) +}) diff --git a/packages/core/test/unit/ssh-proxy-invocation.test.ts b/packages/core/test/unit/ssh-proxy-invocation.test.ts new file mode 100644 index 0000000..7cc3c9a --- /dev/null +++ b/packages/core/test/unit/ssh-proxy-invocation.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { resolveDcwInvocation } from '../../src/core/ssh/attach.js' + +describe('resolveDcwInvocation (#N6 — ProxyCommand quoting)', () => { + it('shell-quotes node/entry paths when falling back to the local install', async () => { + // `command -v dcw` is a shell builtin with no binary on PATH, so capture() + // spawn-fails and we take the ` ` fallback branch. + const inv = await resolveDcwInvocation('my-env') + expect(inv).toContain('ssh-proxy my-env') + if (!inv.startsWith('dcw ')) { + // Fallback form: every path component is single-quoted so spaces don't split. + expect(inv).toMatch(/^'.*' '.*' ssh-proxy my-env$/) + } + }) +}) diff --git a/packages/core/test/unit/state-permissions.test.ts b/packages/core/test/unit/state-permissions.test.ts new file mode 100644 index 0000000..2e32505 --- /dev/null +++ b/packages/core/test/unit/state-permissions.test.ts @@ -0,0 +1,83 @@ +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { saveManifest, writeContainerfile } from '../../src/state/store.js' +import { environmentsDir, manifestPath, containerfilePath } from '../../src/state/paths.js' +import { SCHEMA_VERSION, type EnvManifest } from '../../src/state/manifest.js' +import { EnvSpecSchema } from '../../src/spec/env-spec.js' + +const NOW = '2026-06-11T00:00:00.000Z' +let tmp: string +let prevConfig: string | undefined +let prevState: string | undefined + +beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dcw-perm-')) + prevConfig = process.env.XDG_CONFIG_HOME + prevState = process.env.XDG_STATE_HOME + process.env.XDG_CONFIG_HOME = path.join(tmp, 'config') + process.env.XDG_STATE_HOME = path.join(tmp, 'state') +}) + +afterEach(async () => { + if (prevConfig === undefined) delete process.env.XDG_CONFIG_HOME + else process.env.XDG_CONFIG_HOME = prevConfig + if (prevState === undefined) delete process.env.XDG_STATE_HOME + else process.env.XDG_STATE_HOME = prevState + await fs.rm(tmp, { recursive: true, force: true }) +}) + +function manifest(): EnvManifest { + const spec = EnvSpecSchema.parse({ name: 'demo', hardening: ['drop-caps'] }) + return { + schemaVersion: SCHEMA_VERSION, + name: 'demo', + createdAt: NOW, + updatedAt: NOW, + spec, + resolved: { requiredTools: [], hardeningKeys: spec.hardening }, + engine: 'docker', + image: null, + container: null, + } +} + +async function mode(p: string): Promise { + return ((await fs.stat(p)).mode & 0o777).toString(8) +} + +describe('dcw state file permissions', () => { + it('writes manifests private to the owner (0600), not world-readable', async () => { + await saveManifest(manifest()) + // Manifests record appliedFlags, which embed the absolute workspace path and + // repo/tooling choices — not another local account's business. + expect(await mode(manifestPath('demo'))).toBe('600') + }) + + it('creates the environments directory as 0700', async () => { + await saveManifest(manifest()) + expect(await mode(environmentsDir())).toBe('700') + }) + + it('writes the generated Containerfile as 0600', async () => { + await writeContainerfile('demo', 'FROM debian:trixie\n') + expect(await mode(containerfilePath('demo'))).toBe('600') + }) + + it('tightens a directory an older version left world-readable', async () => { + // mkdir's `mode` only applies to directories it creates, so a dcw dir left 0755 + // by an earlier version would otherwise stay readable by every local account. + await fs.mkdir(environmentsDir(), { recursive: true, mode: 0o755 }) + await fs.chmod(environmentsDir(), 0o755) + await saveManifest(manifest()) + expect(await mode(environmentsDir())).toBe('700') + }) + + it('tightens permissions on rewrite even if a prior file was world-readable', async () => { + await saveManifest(manifest()) + await fs.chmod(manifestPath('demo'), 0o644) + await saveManifest({ ...manifest(), updatedAt: '2026-06-12T00:00:00.000Z' }) + expect(await mode(manifestPath('demo'))).toBe('600') + }) +}) diff --git a/packages/core/test/unit/stop-rm-idempotent.test.ts b/packages/core/test/unit/stop-rm-idempotent.test.ts new file mode 100644 index 0000000..5c6311e --- /dev/null +++ b/packages/core/test/unit/stop-rm-idempotent.test.ts @@ -0,0 +1,161 @@ +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DcwError } from '../../src/errors.js' +import type { ContainerInfo, EngineDriver } from '../../src/engine/types.js' + +// A driver whose ps()/stop()/rm() we can script per test. +const driver = { + name: 'docker', + ps: vi.fn(async (): Promise => []), + stop: vi.fn(async () => {}), + rm: vi.fn(async () => {}), +} as unknown as EngineDriver + +vi.mock('../../src/cli/context.js', async (orig) => { + const actual = await orig() + return { ...actual, resolveEngineFor: vi.fn(async () => ({ driver })) } +}) + +const { default: Stop } = await import('../../src/commands/stop.js') +const { default: Rm } = await import('../../src/commands/rm.js') +const { saveManifest, loadManifest } = await import('../../src/state/store.js') +const { SCHEMA_VERSION } = await import('../../src/state/manifest.js') + +let tmp: string + +beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dcw-stoprm-')) + process.env.XDG_CONFIG_HOME = path.join(tmp, 'config') + process.env.XDG_STATE_HOME = path.join(tmp, 'state') + ;(driver.ps as ReturnType).mockReset().mockResolvedValue([]) + ;(driver.stop as ReturnType).mockReset().mockResolvedValue(undefined) + ;(driver.rm as ReturnType).mockReset().mockResolvedValue(undefined) +}) + +afterEach(async () => { + delete process.env.XDG_CONFIG_HOME + delete process.env.XDG_STATE_HOME + await fs.rm(tmp, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +function manifestWith(container: unknown) { + return { + schemaVersion: SCHEMA_VERSION, + name: 'env1', + createdAt: '2026-06-11T00:00:00.000Z', + updatedAt: '2026-06-11T00:00:00.000Z', + spec: { name: 'env1', engine: 'auto', selections: {}, hardening: [], ssh: true }, + resolved: { requiredTools: [], hardeningKeys: [] }, + engine: null, + image: null, + container, + } +} + +const presentContainer: ContainerInfo = { id: 'c1', name: 'dcw-env1', image: 'img', status: 'Up 2m', labels: {} } + +async function run(Cmd: { prototype: { run(): Promise } }, name: string, flags: Record = {}): Promise { + const cmd = Object.create(Cmd.prototype) + cmd.parse = async () => ({ args: { name }, flags }) + cmd.jsonEnabled = () => true + cmd.log = () => {} + return cmd.run() +} + +describe('stop idempotency (#bug2)', () => { + it('reports stopped:false when no container was ever recorded', async () => { + await saveManifest(manifestWith(null) as never) + const res = await run(Stop, 'env1') + expect(res).toEqual({ name: 'env1', stopped: false }) + expect(driver.stop).not.toHaveBeenCalled() + }) + + it('reports stopped:false when the recorded container is already gone', async () => { + await saveManifest(manifestWith({ id: 'c1', name: 'dcw-env1' }) as never) + ;(driver.ps as ReturnType).mockResolvedValue([]) + const res = await run(Stop, 'env1') + expect(res).toEqual({ name: 'env1', stopped: false }) + expect(driver.stop).not.toHaveBeenCalled() + }) + + it('reports stopped:true and persists stopped status when it actually stops', async () => { + await saveManifest(manifestWith({ id: 'c1', name: 'dcw-env1', status: 'running' }) as never) + ;(driver.ps as ReturnType).mockResolvedValue([presentContainer]) + const res = await run(Stop, 'env1') + expect(res).toEqual({ name: 'env1', stopped: true }) + expect(driver.stop).toHaveBeenCalledOnce() + const reloaded = await loadManifest('env1') + expect(reloaded?.container?.status).toBe('stopped') + }) + + it('lets a genuine engine error surface instead of swallowing it', async () => { + await saveManifest(manifestWith({ id: 'c1', name: 'dcw-env1' }) as never) + ;(driver.ps as ReturnType).mockResolvedValue([presentContainer]) + ;(driver.stop as ReturnType).mockRejectedValue(new DcwError('docker stop failed: boom')) + await expect(run(Stop, 'env1')).rejects.toBeInstanceOf(DcwError) + }) +}) + +describe('rm idempotency (#bug2)', () => { + it('reports removedContainer:false when there is no container to remove', async () => { + await saveManifest(manifestWith(null) as never) + const res = await run(Rm, 'env1') + expect(res).toEqual({ name: 'env1', removedContainer: false, purged: false }) + expect(driver.rm).not.toHaveBeenCalled() + }) + + it('reports removedContainer:true when it actually removes', async () => { + await saveManifest(manifestWith({ id: 'c1', name: 'dcw-env1' }) as never) + ;(driver.ps as ReturnType).mockResolvedValue([presentContainer]) + const res = await run(Rm, 'env1') + expect(res).toEqual({ name: 'env1', removedContainer: true, purged: false }) + expect(driver.rm).toHaveBeenCalledOnce() + }) + + it('still purges the env record even with no container present', async () => { + await saveManifest(manifestWith(null) as never) + const res = await run(Rm, 'env1', { purge: true, yes: true }) + expect(res).toEqual({ name: 'env1', removedContainer: false, purged: true }) + expect(await loadManifest('env1')).toBeNull() + }) + + it('lets a genuine engine error surface instead of swallowing it', async () => { + await saveManifest(manifestWith({ id: 'c1', name: 'dcw-env1' }) as never) + ;(driver.ps as ReturnType).mockResolvedValue([presentContainer]) + ;(driver.rm as ReturnType).mockRejectedValue(new DcwError('docker rm failed: boom')) + await expect(run(Rm, 'env1')).rejects.toBeInstanceOf(DcwError) + }) +}) + +describe('rm --purge on an invalid manifest', () => { + it('purges a record that no longer validates instead of leaving it stuck', async () => { + const { manifestPath } = await import('../../src/state/paths.js') + await fs.mkdir(path.dirname(manifestPath('env1')), { recursive: true }) + // Valid envelope, but spec fails the (tightened) schema: credential-bearing git url. + const bad = manifestWith(null) as Record + ;(bad.spec as Record).gitRepository = { url: 'https://u:tok@h/a/b', enabled: true } + await fs.writeFile(manifestPath('env1'), JSON.stringify(bad)) + ;(driver.ps as ReturnType).mockResolvedValue([presentContainer]) + + const cmd = Object.create(Rm.prototype) + cmd.parse = async () => ({ args: { name: 'env1' }, flags: { purge: true, yes: true } }) + cmd.jsonEnabled = () => true + cmd.log = () => {} + cmd.warn = () => {} + const res = await cmd.run() + + expect(res).toEqual({ name: 'env1', removedContainer: true, purged: true }) + expect(driver.rm).toHaveBeenCalledWith('dcw-env1', { force: true }) + await expect(fs.access(manifestPath('env1'))).rejects.toThrow() + }) + + it('still refuses to touch an invalid manifest without --purge', async () => { + const { manifestPath } = await import('../../src/state/paths.js') + await fs.mkdir(path.dirname(manifestPath('env1')), { recursive: true }) + await fs.writeFile(manifestPath('env1'), '{"schemaVersion":1,"nope":true}') + await expect(run(Rm, 'env1')).rejects.toBeInstanceOf(DcwError) + }) +}) diff --git a/packages/core/test/unit/store.test.ts b/packages/core/test/unit/store.test.ts new file mode 100644 index 0000000..14239c3 --- /dev/null +++ b/packages/core/test/unit/store.test.ts @@ -0,0 +1,111 @@ +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + hashContainerfile, + listManifests, + loadManifest, + removeEnvironment, + saveManifest, + writeContainerfile, +} from '../../src/state/store.js' +import { containerfilePath, manifestPath } from '../../src/state/paths.js' +import { SCHEMA_VERSION, type EnvManifest } from '../../src/state/manifest.js' +import { ValidationError } from '../../src/errors.js' + +let tmp: string + +beforeEach(async () => { + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dcw-store-')) + process.env.XDG_CONFIG_HOME = path.join(tmp, 'config') + process.env.XDG_STATE_HOME = path.join(tmp, 'state') +}) + +afterEach(async () => { + delete process.env.XDG_CONFIG_HOME + delete process.env.XDG_STATE_HOME + await fs.rm(tmp, { recursive: true, force: true }) +}) + +function fixture(name: string): EnvManifest { + return { + schemaVersion: SCHEMA_VERSION, + name, + createdAt: '2026-06-11T00:00:00.000Z', + updatedAt: '2026-06-11T00:00:00.000Z', + spec: { name, engine: 'auto', selections: { frameworks: ['foundry'] }, hardening: ['drop-caps'], ssh: true }, + resolved: { requiredTools: ['rust', 'foundry'], hardeningKeys: ['drop-caps'] }, + engine: null, + image: null, + container: null, + } +} + +describe('store', () => { + it('round-trips a manifest', async () => { + const m = fixture('alpha') + await saveManifest(m) + const loaded = await loadManifest('alpha') + expect(loaded).toEqual(m) + }) + + it('returns null for a missing manifest', async () => { + expect(await loadManifest('nope')).toBeNull() + }) + + it('lists saved manifests sorted by name', async () => { + await saveManifest(fixture('beta')) + await saveManifest(fixture('alpha')) + const names = (await listManifests()).map((m) => m.name) + expect(names).toEqual(['alpha', 'beta']) + }) + + it('removes a manifest and its state dir', async () => { + await saveManifest(fixture('gamma')) + await writeContainerfile('gamma', 'FROM debian:bookworm') + await removeEnvironment('gamma') + expect(await loadManifest('gamma')).toBeNull() + await expect(fs.access(containerfilePath('gamma'))).rejects.toBeTruthy() + }) + + it('writes the Containerfile to the env state dir', async () => { + const p = await writeContainerfile('delta', 'FROM debian:bookworm') + expect(p).toBe(containerfilePath('delta')) + expect(await fs.readFile(p, 'utf8')).toBe('FROM debian:bookworm') + }) + + it('hashes Containerfile content deterministically', () => { + expect(hashContainerfile('abc')).toBe(hashContainerfile('abc')) + expect(hashContainerfile('abc')).not.toBe(hashContainerfile('abd')) + }) + + it('raises a clean ValidationError for malformed manifest JSON (#6)', async () => { + const p = manifestPath('busted') + await fs.mkdir(path.dirname(p), { recursive: true }) + await fs.writeFile(p, '{ "name": "busted", ') // truncated JSON + await expect(loadManifest('busted')).rejects.toBeInstanceOf(ValidationError) + }) + + it('survives concurrent writes to the same target without corruption (#14)', async () => { + await Promise.all(Array.from({ length: 8 }, () => writeContainerfile('race', 'FROM debian:bookworm'))) + expect(await fs.readFile(containerfilePath('race'), 'utf8')).toBe('FROM debian:bookworm') + // No stray .tmp files left behind in the state dir. + const leftovers = (await fs.readdir(path.dirname(containerfilePath('race')))).filter((f) => f.endsWith('.tmp')) + expect(leftovers).toEqual([]) + }) +}) + +describe('manifest filename/name consistency', () => { + it('rejects a manifest whose inner name does not match its filename', async () => { + // Commands key off the filename but then act on the manifest's inner name, so a + // mismatch lets `dcw build a` build, tag and persist state for environment `b` + // — silently clobbering another environment's namespace. listManifests() already + // skips these; loadManifest() must not hand one back. + await saveManifest(fixture('inner')) + await fs.writeFile(manifestPath('outer'), JSON.stringify(fixture('inner'), null, 2)) + await expect(loadManifest('outer')).rejects.toThrow(ValidationError) + // The correctly-named one still loads. + expect((await loadManifest('inner'))?.name).toBe('inner') + }) +}) diff --git a/packages/core/test/unit/strict-preflight.test.ts b/packages/core/test/unit/strict-preflight.test.ts new file mode 100644 index 0000000..e721abe --- /dev/null +++ b/packages/core/test/unit/strict-preflight.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { assertStrictContainer } from '../../src/cli/context.js' +import { StrictHardeningError } from '../../src/errors.js' +import { EnvSpecSchema } from '../../src/spec/env-spec.js' +import { SCHEMA_VERSION, type EnvManifest } from '../../src/state/manifest.js' + +const NOW = '2026-06-11T00:00:00.000Z' + +function manifest(droppedHardening?: string[], unenforcedHardening: string[] = []): EnvManifest { + const spec = EnvSpecSchema.parse({ name: 'demo', hardening: ['network-none', 'drop-caps'] }) + return { + schemaVersion: SCHEMA_VERSION, + name: 'demo', + createdAt: NOW, + updatedAt: NOW, + spec, + resolved: { requiredTools: [], hardeningKeys: spec.hardening }, + engine: 'docker', + image: { tag: 'dcw/demo:latest', imageId: 'sha256:x', containerfileHash: 'h', builtAt: NOW }, + container: droppedHardening + ? { id: 'cid', name: 'dcw-demo', status: 'running', startedAt: NOW, appliedFlags: [], droppedHardening, unenforcedHardening } + : null, + } +} + +describe('assertStrictContainer — --strict must fail closed on already-running containers', () => { + it('throws when entering a container whose hardening was dropped', () => { + expect(() => assertStrictContainer(manifest(['network-none']), true)).toThrow(StrictHardeningError) + }) + + it('names the dropped controls so the user knows what is missing', () => { + expect(() => assertStrictContainer(manifest(['network-none', 'apparmor']), true)).toThrow( + /network-none, apparmor/, + ) + }) + + it('also throws for a control that was emitted but is not enforced', () => { + // AppArmor on a Docker daemon with no LSM: nothing was "dropped", the flag is + // right there on the command line — and it does nothing. Checking only + // droppedHardening would let `dcw shell --strict` walk straight into it. + expect(() => assertStrictContainer(manifest([], ['apparmor']), true)).toThrow(StrictHardeningError) + expect(() => assertStrictContainer(manifest([], ['apparmor']), true)).toThrow(/apparmor/) + }) + + it('allows a container with nothing dropped and nothing unenforced', () => { + expect(() => assertStrictContainer(manifest([], []), true)).not.toThrow() + }) + + it('is a no-op without --strict, so normal use is unaffected', () => { + expect(() => assertStrictContainer(manifest(['network-none']), false)).not.toThrow() + expect(() => assertStrictContainer(manifest(['network-none']), undefined)).not.toThrow() + }) +}) diff --git a/packages/core/test/unit/translator.test.ts b/packages/core/test/unit/translator.test.ts new file mode 100644 index 0000000..c5fd95b --- /dev/null +++ b/packages/core/test/unit/translator.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest' +import { hardeningToEffects } from '../../src/hardening/effects.js' +import { enforceStrict, translate } from '../../src/hardening/translator.js' +import { StrictHardeningError } from '../../src/errors.js' +import { createDriver } from '../../src/engine/registry.js' +import { dockerCaps } from '../../src/engine/drivers/capabilities.js' +import type { HardeningKey } from '../../src/domain/hardening.js' + +const caps = (engine: 'docker' | 'podman' | 'apple-container' | 'lima') => createDriver(engine).capabilities + +// Docker's AppArmor stance depends on what the daemon reports, so pin it explicitly +// — otherwise these assertions would pass against an AppArmor-capable daemon and +// fail against the Linux VM Docker runs on macOS, or vice versa. +function flagsFor( + keys: HardeningKey[], + engine: 'docker' | 'podman' | 'apple-container' | 'lima', + daemonHasApparmor = true, +) { + const capabilities = engine === 'docker' ? dockerCaps(daemonHasApparmor) : caps(engine) + return translate(hardeningToEffects(keys), capabilities, engine) +} + +describe('hardeningToEffects', () => { + it('drop-caps subsumes no-raw-packets (only ALL emitted)', () => { + const effects = hardeningToEffects(['drop-caps', 'no-raw-packets']) + const caps = effects.filter((e) => e.kind === 'drop-cap') + expect(caps).toHaveLength(1) + expect(caps[0]).toMatchObject({ cap: 'ALL' }) + }) + + it('network-none supersedes disable-ipv6', () => { + const effects = hardeningToEffects(['network-none', 'disable-ipv6']) + expect(effects.some((e) => e.kind === 'sysctl')).toBe(false) + expect(effects.some((e) => e.kind === 'network-none')).toBe(true) + }) + + it('readonly-os expands to read-only rootfs plus writable tmpfs (no vscode-server mounts)', () => { + const effects = hardeningToEffects(['readonly-os']) + expect(effects.some((e) => e.kind === 'readonly-rootfs')).toBe(true) + const tmpfsTargets = effects.filter((e) => e.kind === 'tmpfs').map((e) => (e as { target: string }).target) + expect(tmpfsTargets).toContain('/home/vscode/.cache') + expect(tmpfsTargets.some((t) => t.includes('vscode-server'))).toBe(false) + }) +}) + +describe('translate — Docker (full support)', () => { + it('emits the expected flags for a hardened set', () => { + const { flags, dropped, warnings } = flagsFor( + ['drop-caps', 'no-new-privs', 'apparmor', 'secure-dns', 'secure-tmp'], + 'docker', + ) + expect(dropped).toEqual([]) + expect(flags).toContain('--cap-drop=ALL') + expect(flags).toContain('--security-opt') + expect(flags).toContain('no-new-privileges:true') + expect(flags).toContain('apparmor=docker-default') + expect(flags).toContain('--dns') + expect(flags).toContain('1.1.1.1') + expect(warnings).toEqual([]) + }) + + it('maps resource tiers', () => { + const { flags } = flagsFor(['resource-limits-heavy'], 'docker') + expect(flags).toEqual(['--memory', '4g', '--cpus', '8']) + }) + + it('surfaces a caveat for vscode-security (no-op)', () => { + const { flags, warnings } = flagsFor(['vscode-security'], 'docker') + expect(flags).toEqual([]) + expect(warnings.some((w) => w.effect === 'vscode-security' && w.level === 'caveat')).toBe(true) + }) +}) + +describe('translate — Apple Containers (degrades)', () => { + it('drops apparmor / no-new-privs but keeps cap-drop, which the CLI does enforce', () => { + // Verified against `container` CLI 1.0.0: --cap-drop ALL takes CapEff from + // 00000000a80425fb to 0000000000000000, so dropping it would discard real, + // enforced hardening. AppArmor and no-new-privileges genuinely are not exposed. + const { flags, dropped, warnings } = flagsFor(['drop-caps', 'apparmor', 'no-new-privs'], 'apple-container') + expect(flags).toEqual(['--cap-drop=ALL']) + expect(dropped.map((e) => e.kind).sort()).toEqual(['apparmor', 'no-new-privs']) + expect(warnings.every((w) => w.level === 'dropped')).toBe(true) + }) + + it('drops network-none instead of falsely emitting --network=none', () => { + // Apple's `container` CLI does not honor Docker's --network=none, so the + // air-gap must be reported as dropped rather than silently claimed. + const result = flagsFor(['network-none'], 'apple-container') + expect(result.flags).not.toContain('--network=none') + expect(result.dropped.map((e) => e.kind)).toContain('network-none') + expect(() => enforceStrict(result)).toThrow(StrictHardeningError) + }) +}) + +describe('translate — deduplicates tmpfs by target (#9)', () => { + it('keeps a single, most-restrictive --tmpfs per target', () => { + // readonly-os mounts /tmp at 1g; secure-tmp mounts /tmp at 512m. + const { flags } = flagsFor(['readonly-os', 'secure-tmp'], 'docker') + const tmpSpecs = flags.filter((f, i) => flags[i - 1] === '--tmpfs' && f.startsWith('/tmp:')) + expect(tmpSpecs).toHaveLength(1) + expect(tmpSpecs[0]).toContain('size=512m') + }) +}) + +describe('enforceStrict — no-op caveats (#8)', () => { + it('throws for a control that is caveated and not enforced (rootless podman AppArmor)', () => { + const result = flagsFor(['apparmor'], 'podman') + expect(result.flags).toContain('apparmor=docker-default') + expect(result.unenforced.map((e) => e.kind)).toContain('apparmor') + expect(() => enforceStrict(result)).toThrow(StrictHardeningError) + }) + + it('does not throw for advisory caveats on docker (daemon has AppArmor)', () => { + const result = flagsFor(['apparmor'], 'docker', true) + expect(result.unenforced).toEqual([]) + expect(() => enforceStrict(result)).not.toThrow() + }) + + it('throws when the daemon lacks AppArmor, where the flag is accepted but inert', () => { + // Verified on OrbStack 29.4.0 / macOS: `docker inspect` reports an empty + // AppArmorProfile and the container has no LSM, so --strict must fail closed + // rather than certify a control that is not enforced. + const result = flagsFor(['apparmor'], 'docker', false) + expect(result.flags).toContain('apparmor=docker-default') + expect(result.unenforced.map((e) => e.kind)).toContain('apparmor') + expect(() => enforceStrict(result)).toThrow(StrictHardeningError) + }) +}) + +describe('translate — Podman (userns remap)', () => { + it('injects --userns=keep-id when uid-mapped tmpfs is present', () => { + const { flags, warnings } = flagsFor(['readonly-os'], 'podman') + expect(flags[0]).toBe('--userns=keep-id') + expect(warnings.some((w) => w.effect === 'userNamespaces')).toBe(true) + }) + + it('does not inject keep-id without uid-mapped mounts', () => { + const { flags } = flagsFor(['secure-tmp'], 'podman') + expect(flags).not.toContain('--userns=keep-id') + }) + + it('flags AppArmor as caveated but still emits it', () => { + const { flags, warnings } = flagsFor(['apparmor'], 'podman') + expect(flags).toContain('apparmor=docker-default') + expect(warnings.some((w) => w.effect === 'apparmor' && w.level === 'caveat')).toBe(true) + }) +}) + +describe('enforceStrict', () => { + it('throws when hardening was dropped', () => { + const result = flagsFor(['apparmor'], 'apple-container') + expect(() => enforceStrict(result)).toThrow(StrictHardeningError) + }) + + it('throws for a control the engine emits but cannot enforce', () => { + // macOS Docker accepts the AppArmor flag but has no LSM to apply it, so the + // control is emitted yet inert — --strict must not accept that silently. + const result = flagsFor(['apparmor'], 'docker', false) + expect(result.flags).toContain('apparmor=docker-default') + expect(result.unenforced.length).toBeGreaterThan(0) + expect(() => enforceStrict(result)).toThrow(StrictHardeningError) + }) + + it('does not throw when nothing was dropped', () => { + const result = flagsFor(['drop-caps'], 'docker') + expect(() => enforceStrict(result)).not.toThrow() + }) +}) diff --git a/packages/core/test/unit/which.test.ts b/packages/core/test/unit/which.test.ts new file mode 100644 index 0000000..b37df1f --- /dev/null +++ b/packages/core/test/unit/which.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { isOnPath } from '../../src/util/which.js' + +describe('isOnPath', () => { + it('finds a binary that exists on PATH', async () => { + expect(await isOnPath('node')).toBe(true) + }) + + it('reports a missing binary as absent', async () => { + expect(await isOnPath('dcw-definitely-not-a-real-binary-xyz')).toBe(false) + }) + + it('falls back to `which` when `command` is not a spawnable binary', async () => { + // macOS ships /usr/bin/command; most Linux distros do not, so the first probe + // spawn-fails there. Emptying PATH makes BOTH probes unresolvable as binaries, + // reproducing the Linux condition and proving the fallback is what answers. + const realPath = process.env.PATH + process.env.PATH = '' + try { + // `command` is now unspawnable, so a true result can only come from `which` + // resolving via an absolute lookup — and with no PATH, nothing resolves. + expect(await isOnPath('sh')).toBe(false) + } finally { + process.env.PATH = realPath + } + // Restored PATH: resolvable again, through whichever probe answers first. + expect(await isOnPath('sh')).toBe(true) + }) +}) diff --git a/packages/core/test/wizard/wizard.test.tsx b/packages/core/test/wizard/wizard.test.tsx new file mode 100644 index 0000000..df6ffc7 --- /dev/null +++ b/packages/core/test/wizard/wizard.test.tsx @@ -0,0 +1,123 @@ +import { render } from 'ink-testing-library' +import { createElement } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { App } from '../../src/wizard/App.js' +import { createDriver } from '../../src/engine/registry.js' +import type { EngineStatus } from '../../src/engine/resolver.js' +import type { EnvSpec } from '../../src/spec/env-spec.js' + +const ENTER = '\r' + +const tick = () => new Promise((r) => setTimeout(r, 30)) + +function fakeEngines(): EngineStatus[] { + return [ + { + name: 'docker', + displayName: 'Docker', + platform: { supported: true }, + detect: { available: true, version: 'Docker 99' }, + capabilities: createDriver('docker').capabilities, + recommended: true, + }, + { + name: 'apple-container', + displayName: 'Apple Containers', + platform: { supported: false, reason: 'Apple Containers requires macOS.' }, + capabilities: createDriver('apple-container').capabilities, + recommended: false, + }, + ] +} + +describe('wizard App', () => { + it('starts on the engine step and shows engine availability', () => { + const { lastFrame } = render( + createElement(App, { + initial: { fallbackName: 'proj' }, + engines: fakeEngines(), + onComplete: vi.fn(), + onCancel: vi.fn(), + }), + ) + const frame = lastFrame() ?? '' + expect(frame).toContain('Container engine') + expect(frame).toContain('Auto-detect') + expect(frame).toContain('Docker') + // Unsupported engine is shown disabled. + expect(frame).toContain('Apple Containers') + expect(frame).toContain('(unavailable)') + }) + + it('navigates all steps with defaults and produces a spec', async () => { + const onComplete = vi.fn<(spec: EnvSpec) => void>() + const { stdin } = render( + createElement(App, { + initial: { fallbackName: 'my-proj' }, + engines: fakeEngines(), + onComplete, + onCancel: vi.fn(), + }), + ) + + await tick() // let the first step mount before sending input + + // 10 steps, each accepted with Enter + // (engine→name→5 multiselects→hardening(development)→review→confirm). + for (let i = 0; i < 10; i++) { + stdin.write(ENTER) + await tick() + } + + expect(onComplete).toHaveBeenCalledTimes(1) + const spec = onComplete.mock.calls[0]![0] + expect(spec.name).toBe('my-proj') + expect(spec.engine).toBe('auto') + // Accepting every default must not yield an unhardened environment: the + // hardening step now leads with `development` rather than "None". + expect(spec.profile).toBe('development') + expect(spec.hardening).toContain('no-new-privs') + expect(spec.hardening).toContain('secure-tmp') + }) + + it('still allows opting out of hardening explicitly', async () => { + const onComplete = vi.fn<(spec: EnvSpec) => void>() + const { stdin } = render( + createElement(App, { + initial: { fallbackName: 'my-proj' }, + engines: fakeEngines(), + onComplete, + onCancel: vi.fn(), + }), + ) + await tick() + + // engine → name → 6 multiselects lands on the hardening step (9/10). + for (let i = 0; i < 8; i++) { + stdin.write(ENTER) + await tick() + } + // Up wraps to the final choice, which is now "None" rather than the first. + stdin.write('\u001B[A') + await tick() + stdin.write(ENTER) // select None → review + await tick() + stdin.write(ENTER) // confirm review + await tick() + + expect(onComplete).toHaveBeenCalledTimes(1) + expect(onComplete.mock.calls[0]![0].hardening).toEqual([]) + }) + + it('parity: wizard defaults match the flag path for the same inputs', async () => { + const { flagsToSpec } = await import('../../src/spec/flags-to-spec.js') + const fromFlags = flagsToSpec({ fallbackName: 'my-proj', engine: 'auto' }) + expect(fromFlags.name).toBe('my-proj') + // Both entry points must land on the same default posture. + expect(fromFlags.profile).toBe('development') + expect(fromFlags.hardening).toContain('no-new-privs') + + const optedOut = flagsToSpec({ fallbackName: 'my-proj', engine: 'auto', profile: 'none' }) + expect(optedOut.hardening).toEqual([]) + }) +}) diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json new file mode 100644 index 0000000..024a08d --- /dev/null +++ b/packages/core/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["dist", "node_modules", "test", "**/*.test.ts", "**/*.test.tsx"] +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 6aa5241..b14f1f9 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,28 +1,23 @@ { "compilerOptions": { "target": "ES2022", - "module": "CommonJS", - "moduleResolution": "node", - "baseUrl": ".", - "paths": { - "@/*": ["./src/*"] - }, - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "jsx": "react-jsx", "strict": true, + "noUncheckedIndexedAccess": true, + "declaration": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", "skipLibCheck": true, + "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "declaration": true, - "outDir": "dist", - "rootDir": "src" + "verbatimModuleSyntax": true, + "types": ["node"] }, - "include": ["src/**/*.ts"], - "ts-node": { - "transpileOnly": true, - "require": ["tsconfig-paths/register"], - "compilerOptions": { - "module": "CommonJS" - } - } + "include": ["src"], + "exclude": ["dist", "node_modules"] } diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000..6715530 --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts', 'test/**/*.test.tsx'], + environment: 'node', + }, +}) diff --git a/packages/wrapper/LICENSE b/packages/wrapper/LICENSE new file mode 100644 index 0000000..4a668bb --- /dev/null +++ b/packages/wrapper/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The Red Guild + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/wrapper/README.md b/packages/wrapper/README.md index 12b6ffd..cf4eee8 100644 --- a/packages/wrapper/README.md +++ b/packages/wrapper/README.md @@ -1,226 +1,39 @@ -# DevContainer Wizard +# devcontainer-wizard -A comprehensive CLI tool to set up fully equipped Web3 development containers. Features an interactive wizard for creating custom environments with advanced security hardening, git integration, and pre-configured toolchains, or quickly launch pre-built containers for common workflows. +Thin alias package. Installing it installs +[`@theredguild/devcontainer-wizard`](https://www.npmjs.com/package/@theredguild/devcontainer-wizard) +and forwards every invocation to it. -> [!IMPORTANT] -> Dev Containers can improve your workflow, but they are **not a fully secure environment**. -> If you need to run untrusted or suspicious code, use GitHub Codespaces, GitPod, or a similar remote setup — **never run it directly on your machine**. - - -> [!CAUTION] -> **VS Code considerations:** -> -> VS Code does a lot to improve user experience, but that doesn't come without security tradeoffs. VS Code might allow API calls that can lead to running arbitrary commands on the host machine, and by default, it shares sockets such as the gpg-agent’s, which means keys stored outside the container can be used for signing. This opens the door to blind-signing commits scenarios, where a process inside the container may trigger signatures without the user’s full awareness. If you want to deep dive into these "tricks", we're working on an article covering the most relevant of them — stay tuned. - -![DevContainer Wizard](/assets/main.gif) - -## Requirements - -1. **Node.js 18+** and a package manager (**pnpm**, **npm**, or **yarn**) for installing the CLI. - -2. For use with [VS Code](https://code.visualstudio.com/) you need to install the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers). We recommend reading the [Dev Containers documentation](https://code.visualstudio.com/docs/devcontainers/containers) for more information. - -### Full requirements to run Dev Containers - -- **Operating system**: Linux, macOS, or Windows 10/11. On Windows, **WSL2** is recommended for best performance. -- **Container runtime**: One of the following: - - **Docker Desktop** (macOS/Windows) or **Docker Engine** (Linux) with the `docker` CLI available - - Alternatively, **Podman 4+** with the `podman-docker` shim to provide a `docker`-compatible CLI -- **Docker Compose v2**: Available as `docker compose` (bundled with Docker Desktop; on Linux install the Compose plugin). -- **Git**: Version 2.x or later. -- **Node.js 18+** and a package manager (**pnpm**, **npm**, or **yarn**) to install `@devcontainers/cli` globally. -- **Editor**: **VS Code** with the **Dev Containers** extension, or use **GitHub Codespaces** as an alternative (no local runtime required). -- **Permissions**: Ability to run containers (e.g., membership in the `docker` group on Linux, or run with `sudo`). -- **Network access**: To pull base images and extensions on first run. - -## Install - -To install our pre-realease clone this repo and run: - -```bash +```sh npm i -g devcontainer-wizard - -#or - -pnpm add -g devcontainer-wizard +dcw --help ``` -## How to use - -### Quick start - -```bash -devcontainer-wizard -``` - -### Create your own devcontainer - -![DevContainer Wizard](./assets/create.gif) - -```bash -devcontainer-wizard create --name +> [!IMPORTANT] +> **v2 is a complete rewrite and is not backwards compatible with v1.** It no longer +> generates a `devcontainer.json`, and shares no commands with the v1 wizard you may +> already have installed under this name. If you still need the old behavior, pin +> `npm i -g devcontainer-wizard@1`. See the +> [repository README](https://github.com/theredguild/devcontainer-wizard) for the full +> v2 command set and an upgrade guide. + +It exists so the unscoped name keeps working. If you have no existing +dependency on it, install the scoped package directly: + +```sh +npm i -g @theredguild/devcontainer-wizard ``` -The wizard will prompt you for: - -- **Devcontainer name**: defaults to the current directory name. -- **Languages**: Solidity, Vyper. -- **Frameworks**: Foundry, Hardhat, Ape (ApeWorX). -- **Fuzzing & testing**: Echidna, Medusa, Halmos, Ityfuzz, Aderyn. -- **Security tooling**: Slither, Mythril, Crytic (crytic-compile), Panoramix, Semgrep, Heimdall. -- **System hardening**: Choose between predefined security recipes or manual configuration: - - **Security Recipes**: Pre-configured security profiles for common use cases - - **Manual Configuration**: Fine-grained control over individual security options -- **Git repository integration**: Automatically clone a repository during container build - - Repository URL validation - - Optional branch/tag specification -- **VS Code extensions**: Choose from curated extension collections or select your own. -- **Save path**: where `.devcontainer/` will be created. - -When finished, the CLI writes `Dockerfile` and `devcontainer.json` to `.devcontainer/` and offers to start it immediately. It also prints the exact `devcontainer up` command you can run later. - -#### Security Profiles - -The wizard includes predefined security profiles copied from prebuilt devcontainers, so you can build your own container with custom tools and a tested security profile: - -- **Development**: Balanced security for daily development work - - *Features*: Secure temp directories, no privilege escalation, AppArmor, secure DNS, VS Code security - -- **Hardened**: Ephemeral workspace without copying the host folder - - *Features*: Ephemeral workspace, maximum capability restrictions - -- **Air-gapped**: Hardened profile + no network - - *Features*: No network, ephemeral workspace, maximum capability restrictions - -- -Experimental profiles: - -- **Network Restricted Analysis**: API access and package installs without packet crafting -- **CI-like Local Runner**: Mirrors CI behavior with an immutable file system -- **Package Install Session**: Install packages while maintaining security guardrails -- **Security Research (Controlled Net)**: API testing without packet crafting capabilities - -#### Manual Security Hardening Options - -When choosing manual configuration, you have fine-grained control over: - -**File System Security**: -- Read-only file system -- Secure temp directories (noexec, nosuid flags) +Both packages provide the same two binaries — `dcw` and `devcontainer-wizard` — so +installing both globally does not merely conflict, it **fails**: npm aborts with +`EEXIST` and installs nothing. Install one. -**Workspace Isolation**: -- Ephemeral workspace (tmpfs mount) +Already have v1 under this name? `npm i -g devcontainer-wizard@latest` upgrades cleanly +in place. To switch to the scoped package instead, uninstall this one first: -**Container Security**: -- Drop all capabilities -- No new privileges (prevents SUID/SGID escalation) -- AppArmor profile - -**Network Configuration**: -- Enhanced DNS security (Cloudflare DNS) -- Complete network isolation -- Disable IPv6 -- Disable raw packets (prevents packet crafting) - -**Application Security**: -- VS Code security (disables auto-tasks, workspace trust, telemetry) - -**Resource Limits**: -- Light (512MB, 2 cores) -- Standard (2GB, 4 cores) -- Heavy (4GB, 8 cores) - -#### Git Repository Integration - -The wizard can now automatically clone a git repository during container build: - -- **Repository URL**: Supports `https://`, `git@`, `ssh://`, and `git://` protocols -- **Branch/Tag Selection**: Optionally specify a specific branch or tag to clone -- **Validation**: Built-in URL validation ensures proper git repository format -- **Build-time Integration**: Repository is cloned into `/home/vscode/repos` during the image build and copied into `/workspace` on first start - -This feature is particularly useful for: -- Setting up development environments with existing codebases -- Workshop environments with predefined project templates -- Audit environments with specific contract repositories - -#### VS Code Extensions - -The wizard offers curated extension collections: - -- **Recommended** (default): Automatically installs Tintin's Ethereum Security Bundle -- **Custom selection**: Choose from organized collections: - - **Tintin's Extensions**: Security-focused tools (Ethereum Security Bundle, EthOver, WeAudit, Inline Bookmarks, Solidity Language Tools, Graphviz Preview, Decompiler) - - **Nomic Foundation**: Hardhat + Solidity integration - - **Olympix**: AI-powered smart contract analysis - -### Start pre-built containers - -![DevContainer Wizard](./assets/prebuilt.gif) - -Prebuilt containers are stored in the [theredguild/devcontainer](https://github.com/theredguild/devcontainer) repository. - -- **Start a pre-built container**: - -```bash -devcontainer-wizard prebuilt --name +```sh +npm uninstall -g devcontainer-wizard +npm i -g @theredguild/devcontainer-wizard ``` -- **List available pre-built containers**: - -```bash -devcontainer-wizard prebuilt --list -``` - -- **Available pre-built containers**: `minimal`, `auditor`, `Hardened`, `paranoid`, `eth-security-toolbox`, `legacy`. -- You will be prompted how to open it (Terminal, VS Code, or Cursor). - -#### GitHub Codespaces - -You can also run prebuilt containers using GitHub Codespaces: - -[![Open in Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?hide_repo_select=true&ref=main&template_repository=theredguild/devcontainer) - -## Pre-built containers - -- **Minimal**: Use Hardhat and Foundry, doing zero config. -- **Auditor**: Audit smart contracts. -- **Hardened**: Use an Hardened workspace without copying your environment. -- **Air-gapped**: Air-gapped environment. -- **ETH Security Toolbox**: Auditor environment with Trail of Bits selected tools. -- **Legacy**: The Red Guild's original devcontainer. - -## How to contribute - -### Wizard - -We welcome contributions! To get started: - -1. **Fork this repository** and clone it to your machine. -2. **Install dependencies**: - ```bash - pnpm install - ``` -3. **Make your changes** in a new branch. -4. **Test your changes** locally. -5. **Commit and push** your branch. -6. **Open a pull request** with a clear description of your changes. - -For major changes, please open an issue first to discuss what you would like to change. - -**Tips:** -- Follow the existing code style and structure. -- Keep documentation concise and up to date. -- If adding a new color or symbol, update `src/ui/styling/colors.ts` or `src/ui/styling/symbols.ts` as appropriate. - -Thank you for helping improve DevContainer Wizard! - -### Pre-built containers - -We welcome contributions to the pre-built containers! To get started: - -1. **Fork the [theredguild/devcontainer](https://github.com/theredguild/devcontainer) repository** and clone it to your machine. -2. **Make your changes** in a new branch. -3. **Test your changes** locally. -4. **Commit and push** your branch. -5. **Open a pull request** with a clear description of your changes. +Documentation lives in the [repository README](https://github.com/theredguild/devcontainer-wizard). diff --git a/packages/wrapper/bin.js b/packages/wrapper/bin.js index 89b3a23..34e3387 100755 --- a/packages/wrapper/bin.js +++ b/packages/wrapper/bin.js @@ -3,13 +3,13 @@ const { spawn } = require('node:child_process'); let entry; try { - // Prefer the package's default export which should point to the CLI entry - entry = require.resolve('@theredguild/devcontainer-wizard'); + // The CLI entry is ESM; resolve it explicitly rather than via the package's + // "." export, which points at the library index (BaseCommand only). + entry = require.resolve('@theredguild/devcontainer-wizard/bin/run.js'); } catch (e) { - // Detect a common failure mode: older core package missing subpath exports const hint = [ 'Failed to resolve @theredguild/devcontainer-wizard.', - 'If you installed devcontainer-wizard before this fix, update to the latest:', + 'If you installed devcontainer-wizard before v2, update to the latest:', ' pnpm add -g devcontainer-wizard@latest # or npm/yarn equivalent', ].join('\n'); console.error(hint + `\nOriginal error: ${e && e.message}`); @@ -21,5 +21,13 @@ const child = spawn(process.execPath, [entry, ...process.argv.slice(2)], { env: process.env }); +// Forward termination signals so the child isn't orphaned when something +// (e.g. `kill`) signals the wrapper directly instead of the whole process +// group. `child.on('exit')` below re-raises the signal on the wrapper once +// the child actually dies from it, so this does not create a kill loop. +for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) { + process.on(sig, () => { try { child.kill(sig); } catch {} }); +} + child.on('exit', (code, signal) => signal ? process.kill(process.pid, signal) : process.exit(code)); child.on('error', (e) => { console.error('Failed to spawn CLI:', e.message); process.exit(1); }); diff --git a/packages/wrapper/package.json b/packages/wrapper/package.json index 3a9abc0..a7021c3 100644 --- a/packages/wrapper/package.json +++ b/packages/wrapper/package.json @@ -1,22 +1,27 @@ { "name": "devcontainer-wizard", - "version": "1.2.1", + "version": "2.0.0", "description": "Wrapper CLI that delegates to @theredguild/devcontainer-wizard", "license": "MIT", "type": "commonjs", "bin": { - "devcontainer-wizard": "bin.js" + "devcontainer-wizard": "bin.js", + "dcw": "bin.js" }, "dependencies": { - "@oclif/core": "^4.5.2", "@theredguild/devcontainer-wizard": "workspace:*" }, - "files": ["bin.js"], - "publishConfig": { + "files": [ + "bin.js", + "LICENSE" + ], + "publishConfig": { "access": "public", "provenance": true }, - "engines": { "node": ">=18.17" }, + "engines": { + "node": ">=18.17" + }, "repository": { "type": "git", "url": "https://github.com/theredguild/devcontainer-wizard.git", diff --git a/packages/wrapper/pnpm-lock.yaml b/packages/wrapper/pnpm-lock.yaml deleted file mode 100644 index 305a42b..0000000 --- a/packages/wrapper/pnpm-lock.yaml +++ /dev/null @@ -1,620 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@oclif/core': - specifier: ^4.5.2 - version: 4.5.3 - '@theredguild/devcontainer-wizard': - specifier: 1.1.0 - version: 1.1.0 - -packages: - - '@inquirer/checkbox@4.2.2': - resolution: {integrity: sha512-E+KExNurKcUJJdxmjglTl141EwxWyAHplvsYJQgSwXf8qiNWkTxTuCCqmhFEmbIXd4zLaGMfQFJ6WrZ7fSeV3g==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/confirm@5.1.16': - resolution: {integrity: sha512-j1a5VstaK5KQy8Mu8cHmuQvN1Zc62TbLhjJxwHvKPPKEoowSF6h/0UdOpA9DNdWZ+9Inq73+puRq1df6OJ8Sag==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/core@10.2.0': - resolution: {integrity: sha512-NyDSjPqhSvpZEMZrLCYUquWNl+XC/moEcVFqS55IEYIYsY0a1cUCevSqk7ctOlnm/RaSBU5psFryNlxcmGrjaA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/editor@4.2.18': - resolution: {integrity: sha512-yeQN3AXjCm7+Hmq5L6Dm2wEDeBRdAZuyZ4I7tWSSanbxDzqM0KqzoDbKM7p4ebllAYdoQuPJS6N71/3L281i6w==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/expand@4.0.18': - resolution: {integrity: sha512-xUjteYtavH7HwDMzq4Cn2X4Qsh5NozoDHCJTdoXg9HfZ4w3R6mxV1B9tL7DGJX2eq/zqtsFjhm0/RJIMGlh3ag==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/external-editor@1.0.1': - resolution: {integrity: sha512-Oau4yL24d2B5IL4ma4UpbQigkVhzPDXLoqy1ggK4gnHg/stmkffJE4oOXHXF3uz0UEpywG68KcyXsyYpA1Re/Q==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/figures@1.0.13': - resolution: {integrity: sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==} - engines: {node: '>=18'} - - '@inquirer/input@4.2.2': - resolution: {integrity: sha512-hqOvBZj/MhQCpHUuD3MVq18SSoDNHy7wEnQ8mtvs71K8OPZVXJinOzcvQna33dNYLYE4LkA9BlhAhK6MJcsVbw==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/number@3.0.18': - resolution: {integrity: sha512-7exgBm52WXZRczsydCVftozFTrrwbG5ySE0GqUd2zLNSBXyIucs2Wnm7ZKLe/aUu6NUg9dg7Q80QIHCdZJiY4A==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/password@4.0.18': - resolution: {integrity: sha512-zXvzAGxPQTNk/SbT3carAD4Iqi6A2JS2qtcqQjsL22uvD+JfQzUrDEtPjLL7PLn8zlSNyPdY02IiQjzoL9TStA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/prompts@7.8.4': - resolution: {integrity: sha512-MuxVZ1en1g5oGamXV3DWP89GEkdD54alcfhHd7InUW5BifAdKQEK9SLFa/5hlWbvuhMPlobF0WAx7Okq988Jxg==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/rawlist@4.1.6': - resolution: {integrity: sha512-KOZqa3QNr3f0pMnufzL7K+nweFFCCBs6LCXZzXDrVGTyssjLeudn5ySktZYv1XiSqobyHRYYK0c6QsOxJEhXKA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/search@3.1.1': - resolution: {integrity: sha512-TkMUY+A2p2EYVY3GCTItYGvqT6LiLzHBnqsU1rJbrpXUijFfM6zvUx0R4civofVwFCmJZcKqOVwwWAjplKkhxA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/select@4.3.2': - resolution: {integrity: sha512-nwous24r31M+WyDEHV+qckXkepvihxhnyIaod2MG7eCE6G0Zm/HUF6jgN8GXgf4U7AU6SLseKdanY195cwvU6w==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/type@3.0.8': - resolution: {integrity: sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@oclif/core@4.5.3': - resolution: {integrity: sha512-ISoFlfmsuxJvNKXhabCO4/KqNXDQdLHchZdTPfZbtqAsQbqTw5IKitLVZq9Sz1LWizN37HILp4u0350B8scBjg==} - engines: {node: '>=18.0.0'} - - '@theredguild/devcontainer-wizard@1.1.0': - resolution: {integrity: sha512-E3uAGzx08uIFb2A7tp4pBP+lVRdCWr/yTn17/x9f6zTzMC8Y8clGCuvvyiBlspTrwDNZ5OALY+vDgU06yJoJ8A==} - engines: {node: '>=18.17'} - - ansi-escapes@4.3.2: - resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} - engines: {node: '>=8'} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - ansis@3.17.0: - resolution: {integrity: sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==} - engines: {node: '>=14'} - - async@3.2.6: - resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - - chardet@2.1.0: - resolution: {integrity: sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==} - - clean-stack@3.0.1: - resolution: {integrity: sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==} - engines: {node: '>=10'} - - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} - - cli-width@4.1.0: - resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} - engines: {node: '>= 12'} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - debug@4.4.1: - resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - ejs@3.1.10: - resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} - engines: {node: '>=0.10.0'} - hasBin: true - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - filelist@1.0.4: - resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} - - get-package-type@0.1.0: - resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} - engines: {node: '>=8.0.0'} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - - indent-string@4.0.0: - resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} - engines: {node: '>=8'} - - is-docker@2.2.1: - resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} - engines: {node: '>=8'} - hasBin: true - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-wsl@2.2.0: - resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} - engines: {node: '>=8'} - - jake@10.9.4: - resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} - engines: {node: '>=10'} - hasBin: true - - lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} - - minimatch@5.1.6: - resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} - engines: {node: '>=10'} - - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} - engines: {node: '>=16 || 14 >=14.17'} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - mute-stream@2.0.0: - resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} - engines: {node: ^18.17.0 || >=20.5.0} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - semver@7.7.2: - resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} - engines: {node: '>=10'} - hasBin: true - - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} - - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} - - type-fest@0.21.3: - resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} - engines: {node: '>=10'} - - widest-line@3.1.0: - resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} - engines: {node: '>=8'} - - wordwrap@1.0.0: - resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - - wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - yoctocolors-cjs@2.1.3: - resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} - engines: {node: '>=18'} - -snapshots: - - '@inquirer/checkbox@4.2.2': - dependencies: - '@inquirer/core': 10.2.0 - '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8 - ansi-escapes: 4.3.2 - yoctocolors-cjs: 2.1.3 - - '@inquirer/confirm@5.1.16': - dependencies: - '@inquirer/core': 10.2.0 - '@inquirer/type': 3.0.8 - - '@inquirer/core@10.2.0': - dependencies: - '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8 - ansi-escapes: 4.3.2 - cli-width: 4.1.0 - mute-stream: 2.0.0 - signal-exit: 4.1.0 - wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.3 - - '@inquirer/editor@4.2.18': - dependencies: - '@inquirer/core': 10.2.0 - '@inquirer/external-editor': 1.0.1 - '@inquirer/type': 3.0.8 - - '@inquirer/expand@4.0.18': - dependencies: - '@inquirer/core': 10.2.0 - '@inquirer/type': 3.0.8 - yoctocolors-cjs: 2.1.3 - - '@inquirer/external-editor@1.0.1': - dependencies: - chardet: 2.1.0 - iconv-lite: 0.6.3 - - '@inquirer/figures@1.0.13': {} - - '@inquirer/input@4.2.2': - dependencies: - '@inquirer/core': 10.2.0 - '@inquirer/type': 3.0.8 - - '@inquirer/number@3.0.18': - dependencies: - '@inquirer/core': 10.2.0 - '@inquirer/type': 3.0.8 - - '@inquirer/password@4.0.18': - dependencies: - '@inquirer/core': 10.2.0 - '@inquirer/type': 3.0.8 - ansi-escapes: 4.3.2 - - '@inquirer/prompts@7.8.4': - dependencies: - '@inquirer/checkbox': 4.2.2 - '@inquirer/confirm': 5.1.16 - '@inquirer/editor': 4.2.18 - '@inquirer/expand': 4.0.18 - '@inquirer/input': 4.2.2 - '@inquirer/number': 3.0.18 - '@inquirer/password': 4.0.18 - '@inquirer/rawlist': 4.1.6 - '@inquirer/search': 3.1.1 - '@inquirer/select': 4.3.2 - - '@inquirer/rawlist@4.1.6': - dependencies: - '@inquirer/core': 10.2.0 - '@inquirer/type': 3.0.8 - yoctocolors-cjs: 2.1.3 - - '@inquirer/search@3.1.1': - dependencies: - '@inquirer/core': 10.2.0 - '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8 - yoctocolors-cjs: 2.1.3 - - '@inquirer/select@4.3.2': - dependencies: - '@inquirer/core': 10.2.0 - '@inquirer/figures': 1.0.13 - '@inquirer/type': 3.0.8 - ansi-escapes: 4.3.2 - yoctocolors-cjs: 2.1.3 - - '@inquirer/type@3.0.8': {} - - '@oclif/core@4.5.3': - dependencies: - ansi-escapes: 4.3.2 - ansis: 3.17.0 - clean-stack: 3.0.1 - cli-spinners: 2.9.2 - debug: 4.4.1(supports-color@8.1.1) - ejs: 3.1.10 - get-package-type: 0.1.0 - indent-string: 4.0.0 - is-wsl: 2.2.0 - lilconfig: 3.1.3 - minimatch: 9.0.5 - semver: 7.7.2 - string-width: 4.2.3 - supports-color: 8.1.1 - tinyglobby: 0.2.15 - widest-line: 3.1.0 - wordwrap: 1.0.0 - wrap-ansi: 7.0.0 - - '@theredguild/devcontainer-wizard@1.1.0': - dependencies: - '@inquirer/core': 10.2.0 - '@inquirer/prompts': 7.8.4 - '@oclif/core': 4.5.3 - transitivePeerDependencies: - - '@types/node' - - ansi-escapes@4.3.2: - dependencies: - type-fest: 0.21.3 - - ansi-regex@5.0.1: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - ansis@3.17.0: {} - - async@3.2.6: {} - - balanced-match@1.0.2: {} - - brace-expansion@2.0.2: - dependencies: - balanced-match: 1.0.2 - - chardet@2.1.0: {} - - clean-stack@3.0.1: - dependencies: - escape-string-regexp: 4.0.0 - - cli-spinners@2.9.2: {} - - cli-width@4.1.0: {} - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - debug@4.4.1(supports-color@8.1.1): - dependencies: - ms: 2.1.3 - optionalDependencies: - supports-color: 8.1.1 - - ejs@3.1.10: - dependencies: - jake: 10.9.4 - - emoji-regex@8.0.0: {} - - escape-string-regexp@4.0.0: {} - - fdir@6.5.0(picomatch@4.0.3): - optionalDependencies: - picomatch: 4.0.3 - - filelist@1.0.4: - dependencies: - minimatch: 5.1.6 - - get-package-type@0.1.0: {} - - has-flag@4.0.0: {} - - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - - indent-string@4.0.0: {} - - is-docker@2.2.1: {} - - is-fullwidth-code-point@3.0.0: {} - - is-wsl@2.2.0: - dependencies: - is-docker: 2.2.1 - - jake@10.9.4: - dependencies: - async: 3.2.6 - filelist: 1.0.4 - picocolors: 1.1.1 - - lilconfig@3.1.3: {} - - minimatch@5.1.6: - dependencies: - brace-expansion: 2.0.2 - - minimatch@9.0.5: - dependencies: - brace-expansion: 2.0.2 - - ms@2.1.3: {} - - mute-stream@2.0.0: {} - - picocolors@1.1.1: {} - - picomatch@4.0.3: {} - - safer-buffer@2.1.2: {} - - semver@7.7.2: {} - - signal-exit@4.1.0: {} - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - supports-color@8.1.1: - dependencies: - has-flag: 4.0.0 - - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - - type-fest@0.21.3: {} - - widest-line@3.1.0: - dependencies: - string-width: 4.2.3 - - wordwrap@1.0.0: {} - - wrap-ansi@6.2.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - yoctocolors-cjs@2.1.3: {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ad92fd..8cc8e85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,55 +14,56 @@ importers: packages/core: dependencies: - '@inquirer/core': - specifier: ^10.1.15 - version: 10.1.15(@types/node@18.19.122) - '@inquirer/prompts': - specifier: ^7.8.1 - version: 7.8.3(@types/node@18.19.122) '@oclif/core': specifier: ^4.5.2 version: 4.5.2 - semver: - specifier: ^7.6.3 - version: 7.7.2 + ink: + specifier: ^5.1.0 + version: 5.2.1(@types/react@18.3.31)(react@18.3.1) + react: + specifier: ^18.3.1 + version: 18.3.1 + zod: + specifier: ^3.23.8 + version: 3.25.76 + zod-to-json-schema: + specifier: ^3.23.5 + version: 3.25.2(zod@3.25.76) devDependencies: - '@oclif/prettier-config': - specifier: ^0.2.1 - version: 0.2.1 '@types/node': - specifier: ^18.19.122 + specifier: ^18.19.0 version: 18.19.122 - '@types/semver': - specifier: ^7.5.8 - version: 7.7.1 + '@types/react': + specifier: ^18.3.12 + version: 18.3.31 + ink-testing-library: + specifier: ^4.0.0 + version: 4.0.0(@types/react@18.3.31) oclif: specifier: ^4.5.0 version: 4.22.12(@types/node@18.19.122) - ts-node: - specifier: ^10.9.2 - version: 10.9.2(@types/node@18.19.122)(typescript@5.9.2) - tsc-alias: - specifier: ^1.8.10 - version: 1.8.16 - tsconfig-paths: - specifier: ^4.2.0 - version: 4.2.0 + tsx: + specifier: ^4.19.2 + version: 4.23.12 typescript: specifier: ^5.9.2 version: 5.9.2 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@18.19.122) packages/wrapper: dependencies: - '@oclif/core': - specifier: ^4.5.2 - version: 4.5.2 '@theredguild/devcontainer-wizard': specifier: workspace:* version: link:../core packages: + '@alcalzone/ansi-tokenize@0.1.3': + resolution: {integrity: sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==} + engines: {node: '>=14.13.1'} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -218,9 +219,299 @@ packages: resolution: {integrity: sha512-6Ed0kmC1NMbuFTEgNmamAUU1h5gShgxL1hBVLbEzUa3trX5aJBz1vU4bXaBTvOYUAnOHtiy1Ml4AMStd6hJnFA==} engines: {node: '>=18.0.0'} - '@cspotcode/source-map-support@0.8.1': - resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] '@inquirer/checkbox@4.2.1': resolution: {integrity: sha512-bevKGO6kX1eM/N+pdh9leS5L7TBF4ICrzi9a+cbWkrxeAeIcwlo/7OfWGCDERdRCI2/Q6tjltX4bt07ALHDwFw==} @@ -376,27 +667,14 @@ packages: '@types/node': optional: true - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.4': - resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/trace-mapping@0.3.9': - resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] '@oclif/core@4.5.2': resolution: {integrity: sha512-eQcKyrEcDYeZJKu4vUWiu0ii/1Gfev6GF4FsLSgNez5/+aQyAUCjg3ZWlurf491WiYZTXCWyKAxyPWk8DKv2MA==} @@ -414,9 +692,6 @@ packages: resolution: {integrity: sha512-YDlr//SHmC80eZrt+0wNFWSo1cOSU60RoWdhSkAoPB3pUGPSNHZDquXDpo7KniinzYPsj1rfetCYk7UVXwYu7A==} engines: {node: '>=18.0.0'} - '@oclif/prettier-config@0.2.1': - resolution: {integrity: sha512-XB8kwQj8zynXjIIWRm+6gO/r8Qft2xKtwBMSmq1JRqtA6TpwpqECqiu8LosBCyg2JBXuUy2lU23/L98KIR7FrQ==} - '@pnpm/config.env-replace@1.1.0': resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} @@ -429,6 +704,131 @@ packages: resolution: {integrity: sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==} engines: {node: '>=12'} + '@rollup/rollup-android-arm-eabi@4.62.4': + resolution: {integrity: sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.4': + resolution: {integrity: sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.4': + resolution: {integrity: sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.4': + resolution: {integrity: sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.4': + resolution: {integrity: sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.4': + resolution: {integrity: sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.62.4': + resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.4': + resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.62.4': + resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.62.4': + resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.62.4': + resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.4': + resolution: {integrity: sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + resolution: {integrity: sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + resolution: {integrity: sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.4': + resolution: {integrity: sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.4': + resolution: {integrity: sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==} + cpu: [x64] + os: [win32] + '@sindresorhus/is@5.6.0': resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==} engines: {node: '>=14.16'} @@ -649,17 +1049,8 @@ packages: resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} engines: {node: '>=14.16'} - '@tsconfig/node10@1.0.11': - resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} - - '@tsconfig/node12@1.0.11': - resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} - - '@tsconfig/node14@1.0.3': - resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} - - '@tsconfig/node16@1.0.4': - resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} '@types/http-cache-semantics@4.0.4': resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==} @@ -673,8 +1064,11 @@ packages: '@types/node@22.17.2': resolution: {integrity: sha512-gL6z5N9Jm9mhY+U2KXZpteb+09zyffliRkZyZOHODGATyC5B1Jt/7TzuuiLkFsSUMLbS1OLmlj/E+/3KF4Q/4w==} - '@types/semver@7.7.1': - resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react@18.3.31': + resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} '@types/uuid@9.0.8': resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} @@ -682,41 +1076,66 @@ packages: '@types/wrap-ansi@3.0.0': resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} - acorn-walk@8.3.4: - resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} - engines: {node: '>=0.4.0'} + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} - engines: {node: '>=0.4.0'} - hasBin: true + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + ansis@3.17.0: resolution: {integrity: sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==} engines: {node: '>=14'} - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - - arg@4.1.3: - resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} - - array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} async-retry@1.3.3: resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} @@ -724,13 +1143,13 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + auto-bind@5.0.1: + resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} - bowser@2.12.0: resolution: {integrity: sha512-HcOcTudTeEWgbHh0Y1Tyb6fdeR71m4b/QACf0D4KswGTsNeIJQmg38mRENZPAYPZvGFN3fk3604XbQEPdxXdKg==} @@ -741,6 +1160,10 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + cacheable-lookup@7.0.0: resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==} engines: {node: '>=14.16'} @@ -755,28 +1178,52 @@ packages: capital-case@1.0.4: resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + change-case@4.1.2: resolution: {integrity: sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==} chardet@2.1.0: resolution: {integrity: sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==} - chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} - engines: {node: '>= 8.10.0'} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} clean-stack@3.0.1: resolution: {integrity: sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==} engines: {node: '>=10'} + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + + cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cli-spinners@2.9.2: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cli-truncate@4.0.0: + resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} + engines: {node: '>=18'} + cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + code-excerpt@4.0.0: + resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -784,10 +1231,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - commander@9.5.0: - resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} - engines: {node: ^12.20.0 || >=14} - config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} @@ -798,8 +1241,12 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} - create-require@1.1.1: - resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + convert-to-spaces@2.0.1: + resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} @@ -814,6 +1261,10 @@ packages: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + defer-to-connect@2.0.1: resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} engines: {node: '>=10'} @@ -826,14 +1277,6 @@ packages: resolution: {integrity: sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - diff@4.0.2: - resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} - engines: {node: '>=0.3.1'} - - dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} - dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} @@ -842,19 +1285,49 @@ packages: engines: {node: '>=0.10.0'} hasBin: true + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + error-ex@1.3.2: resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-toolkit@1.51.0: + resolution: {integrity: sha512-zC2lQGkM7QX+Gm6iM3+WIdZJzthsEd14LvRNJneSO2hzyz/zNBENR8+YXWo1cKxgPBtV6ksPYHELbcwBRzmdCw==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} fast-levenshtein@3.0.0: resolution: {integrity: sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ==} @@ -867,9 +1340,6 @@ packages: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} engines: {node: '>= 4.9.1'} - fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} - fdir@6.4.6: resolution: {integrity: sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==} peerDependencies: @@ -901,6 +1371,10 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-package-type@0.1.0: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} @@ -913,23 +1387,12 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} - get-tsconfig@4.10.1: - resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} - git-hooks-list@3.2.0: resolution: {integrity: sha512-ZHG9a1gEhUMX1TvGrLdyWb9kDopCBbTnI8z4JgRMYxsijWipgjSEYoPWqBuIB0DnRnvqlQSEeVmzpeuPm7NdFQ==} github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - - globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} - got@13.0.0: resolution: {integrity: sha512-XfBk1CxOOScDcMr9O1yKkNaQyy865NbYs+F7dr4H0LZMVgCj2Le59k6PqbNHoL5ToeaEQUYh6c6yMfVcc6SJxA==} engines: {node: '>=16'} @@ -966,40 +1429,63 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + indent-string@5.0.0: + resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} + engines: {node: '>=12'} + ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ink-testing-library@4.0.0: + resolution: {integrity: sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=18.0.0' + peerDependenciesMeta: + '@types/react': + optional: true + + ink@5.2.1: + resolution: {integrity: sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=18.0.0' + react: '>=18.0.0' + react-devtools-core: ^4.19.1 + peerDependenciesMeta: + '@types/react': + optional: true + react-devtools-core: + optional: true + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} - is-docker@2.2.1: resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} engines: {node: '>=8'} hasBin: true - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} + is-fullwidth-code-point@4.0.0: + resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} + engines: {node: '>=12'} + + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} + engines: {node: '>=18'} + + is-in-ci@1.0.0: + resolution: {integrity: sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==} + engines: {node: '>=18'} + hasBin: true is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} @@ -1026,17 +1512,15 @@ packages: engines: {node: '>=10'} hasBin: true + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} json-parse-better-errors@1.0.2: resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} @@ -1050,6 +1534,13 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -1060,17 +1551,17 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - make-error@1.3.6: - resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} - - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} @@ -1087,9 +1578,6 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1101,9 +1589,10 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} - mylas@2.1.13: - resolution: {integrity: sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg==} - engines: {node: '>=12.0.0'} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} @@ -1112,10 +1601,6 @@ packages: resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} engines: {node: ^16.14.0 || >=18.0.0} - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - normalize-url@8.0.2: resolution: {integrity: sha512-Ee/R3SyN4BuynXcnTaekmaVdbDAEiNrHqjQIA37mHU8G9pf7aaAD4ZX3XjBLo6rsdcxA/gtkcNYZLt30ACgynw==} engines: {node: '>=14.16'} @@ -1125,6 +1610,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + p-cancelable@3.0.0: resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==} engines: {node: '>=12.20'} @@ -1139,12 +1628,19 @@ packages: pascal-case@3.1.2: resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} + patch-console@2.0.0: + resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-case@3.0.4: resolution: {integrity: sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==} - path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1157,27 +1653,26 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - plimit-lit@1.6.1: - resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==} - engines: {node: '>=12'} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} - queue-lit@1.5.2: - resolution: {integrity: sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==} - engines: {node: '>=12'} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} - readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} + react-reconciler@0.29.2: + resolution: {integrity: sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^18.3.1 + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} registry-auth-token@5.1.0: resolution: {integrity: sha512-GdekYuwLXLxMuFTwAPg5UKGLW/UXzQrZvH/Zj791BQif5T05T0RsaLfHc9q3ZOKi7n+BoprPD9mJ0O0k4xzUlw==} @@ -1186,23 +1681,22 @@ packages: resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - responselike@3.0.0: resolution: {integrity: sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==} engines: {node: '>=14.16'} + restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rollup@4.62.4: + resolution: {integrity: sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -1210,6 +1704,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + semver@7.7.2: resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} engines: {node: '>=10'} @@ -1218,13 +1715,23 @@ packages: sentence-case@3.0.4: resolution: {integrity: sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} + slice-ansi@5.0.0: + resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} + engines: {node: '>=12'} + + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} + engines: {node: '>=18'} snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} @@ -1236,6 +1743,10 @@ packages: resolution: {integrity: sha512-9x9+o8krTT2saA9liI4BljNjwAbvUnWf11Wq+i/iZt8nl2UGYnf3TH5uBydE7VALmP7AGwlfszuEeL8BDyb0YA==} hasBin: true + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + spdx-correct@3.2.0: resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} @@ -1248,17 +1759,31 @@ packages: spdx-license-ids@3.0.22: resolution: {integrity: sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==} + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-bom@3.0.0: - resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} - engines: {node: '>=4'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} strnum@2.1.1: resolution: {integrity: sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==} @@ -1270,40 +1795,40 @@ packages: tiny-jsonc@1.0.2: resolution: {integrity: sha512-f5QDAfLq6zIVSyCZQZhhyl0QS6MvAyTxgz4X4x3+EoCktNWEYJ6PeoEA97fyb98njpBNNi88ybpD7m+BDFXaCw==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.14: resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} engines: {node: '>=12.0.0'} - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} - ts-node@10.9.2: - resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} - hasBin: true - peerDependencies: - '@swc/core': '>=1.2.50' - '@swc/wasm': '>=1.2.50' - '@types/node': '*' - typescript: '>=2.7' - peerDependenciesMeta: - '@swc/core': - optional: true - '@swc/wasm': - optional: true + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} - tsc-alias@1.8.16: - resolution: {integrity: sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==} - engines: {node: '>=16.20.2'} - hasBin: true + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} - tsconfig-paths@4.2.0: - resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} - engines: {node: '>=6'} + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -1311,6 +1836,10 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typescript@5.9.2: resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} @@ -1336,9 +1865,6 @@ packages: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true - v8-compile-cache-lib@3.0.1: - resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} - validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -1346,10 +1872,80 @@ packages: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + widest-line@3.1.0: resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} engines: {node: '>=8'} + widest-line@5.0.0: + resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} + engines: {node: '>=18'} + wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} @@ -1361,16 +1957,44 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} - yn@3.1.1: - resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} - engines: {node: '>=6'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true yoctocolors-cjs@2.1.2: resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} engines: {node: '>=18'} + yoga-layout@3.2.1: + resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + snapshots: + '@alcalzone/ansi-tokenize@0.1.3': + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 4.0.0 + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -1852,41 +2476,184 @@ snapshots: dependencies: tslib: 2.8.1 - '@aws-sdk/util-endpoints@3.862.0': - dependencies: - '@aws-sdk/types': 3.862.0 - '@smithy/types': 4.3.2 - '@smithy/url-parser': 4.0.5 - '@smithy/util-endpoints': 3.0.7 - tslib: 2.8.1 + '@aws-sdk/util-endpoints@3.862.0': + dependencies: + '@aws-sdk/types': 3.862.0 + '@smithy/types': 4.3.2 + '@smithy/url-parser': 4.0.5 + '@smithy/util-endpoints': 3.0.7 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.804.0': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.862.0': + dependencies: + '@aws-sdk/types': 3.862.0 + '@smithy/types': 4.3.2 + bowser: 2.12.0 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.864.0': + dependencies: + '@aws-sdk/middleware-user-agent': 3.864.0 + '@aws-sdk/types': 3.862.0 + '@smithy/node-config-provider': 4.1.4 + '@smithy/types': 4.3.2 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.862.0': + dependencies: + '@smithy/types': 4.3.2 + tslib: 2.8.1 + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true - '@aws-sdk/util-locate-window@3.804.0': - dependencies: - tslib: 2.8.1 + '@esbuild/win32-arm64@0.21.5': + optional: true - '@aws-sdk/util-user-agent-browser@3.862.0': - dependencies: - '@aws-sdk/types': 3.862.0 - '@smithy/types': 4.3.2 - bowser: 2.12.0 - tslib: 2.8.1 + '@esbuild/win32-arm64@0.28.2': + optional: true - '@aws-sdk/util-user-agent-node@3.864.0': - dependencies: - '@aws-sdk/middleware-user-agent': 3.864.0 - '@aws-sdk/types': 3.862.0 - '@smithy/node-config-provider': 4.1.4 - '@smithy/types': 4.3.2 - tslib: 2.8.1 + '@esbuild/win32-ia32@0.21.5': + optional: true - '@aws-sdk/xml-builder@3.862.0': - dependencies: - '@smithy/types': 4.3.2 - tslib: 2.8.1 + '@esbuild/win32-ia32@0.28.2': + optional: true - '@cspotcode/source-map-support@0.8.1': - dependencies: - '@jridgewell/trace-mapping': 0.3.9 + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true '@inquirer/checkbox@4.2.1(@types/node@18.19.122)': dependencies: @@ -2052,26 +2819,10 @@ snapshots: optionalDependencies: '@types/node': 18.19.122 - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.4': {} - - '@jridgewell/trace-mapping@0.3.9': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.4 - - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} + '@jridgewell/sourcemap-codec@1.5.5': {} - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.19.1 + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true '@oclif/core@4.5.2': dependencies: @@ -2118,8 +2869,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@oclif/prettier-config@0.2.1': {} - '@pnpm/config.env-replace@1.1.0': {} '@pnpm/network.ca-file@1.0.2': @@ -2132,6 +2881,81 @@ snapshots: '@pnpm/network.ca-file': 1.0.2 config-chain: 1.1.13 + '@rollup/rollup-android-arm-eabi@4.62.4': + optional: true + + '@rollup/rollup-android-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.4': + optional: true + + '@rollup/rollup-darwin-x64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.4': + optional: true + '@sindresorhus/is@5.6.0': {} '@smithy/abort-controller@4.0.5': @@ -2474,13 +3298,7 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@tsconfig/node10@1.0.11': {} - - '@tsconfig/node12@1.0.11': {} - - '@tsconfig/node14@1.0.3': {} - - '@tsconfig/node16@1.0.4': {} + '@types/estree@1.0.9': {} '@types/http-cache-semantics@4.0.4': {} @@ -2496,38 +3314,78 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/semver@7.7.1': {} + '@types/prop-types@15.7.15': {} + + '@types/react@18.3.31': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 '@types/uuid@9.0.8': {} '@types/wrap-ansi@3.0.0': {} - acorn-walk@8.3.4: + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@18.19.122))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@18.19.122) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': dependencies: - acorn: 8.15.0 + tinyspy: 3.0.2 - acorn@8.15.0: {} + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + ansi-regex@5.0.1: {} + ansi-regex@6.3.0: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 - ansis@3.17.0: {} - - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 + ansi-styles@6.2.3: {} - arg@4.1.3: {} + ansis@3.17.0: {} - array-union@2.1.0: {} + assertion-error@2.0.1: {} async-retry@1.3.3: dependencies: @@ -2535,9 +3393,9 @@ snapshots: async@3.2.6: {} - balanced-match@1.0.2: {} + auto-bind@5.0.1: {} - binary-extensions@2.3.0: {} + balanced-match@1.0.2: {} bowser@2.12.0: {} @@ -2549,6 +3407,8 @@ snapshots: dependencies: fill-range: 7.1.1 + cac@6.7.14: {} + cacheable-lookup@7.0.0: {} cacheable-request@10.2.14: @@ -2572,6 +3432,16 @@ snapshots: tslib: 2.8.1 upper-case-first: 2.0.2 + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@5.6.2: {} + change-case@4.1.2: dependencies: camel-case: 4.1.2 @@ -2589,34 +3459,37 @@ snapshots: chardet@2.1.0: {} - chokidar@3.6.0: - dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 + check-error@2.1.3: {} clean-stack@3.0.1: dependencies: escape-string-regexp: 4.0.0 + cli-boxes@3.0.0: {} + + cli-cursor@4.0.0: + dependencies: + restore-cursor: 4.0.0 + cli-spinners@2.9.2: {} + cli-truncate@4.0.0: + dependencies: + slice-ansi: 5.0.0 + string-width: 7.2.0 + cli-width@4.1.0: {} + code-excerpt@4.0.0: + dependencies: + convert-to-spaces: 2.0.1 + color-convert@2.0.1: dependencies: color-name: 1.1.4 color-name@1.1.4: {} - commander@9.5.0: {} - config-chain@1.1.13: dependencies: ini: 1.3.8 @@ -2630,7 +3503,9 @@ snapshots: content-type@1.0.5: {} - create-require@1.1.1: {} + convert-to-spaces@2.0.1: {} + + csstype@3.2.3: {} debug@4.4.1(supports-color@8.1.1): dependencies: @@ -2642,18 +3517,14 @@ snapshots: dependencies: mimic-response: 3.1.0 + deep-eql@5.0.2: {} + defer-to-connect@2.0.1: {} detect-indent@7.0.1: {} detect-newline@4.0.1: {} - diff@4.0.2: {} - - dir-glob@3.0.1: - dependencies: - path-type: 4.0.0 - dot-case@3.0.4: dependencies: no-case: 3.0.4 @@ -2663,21 +3534,84 @@ snapshots: dependencies: jake: 10.9.4 + emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} + environment@1.1.0: {} + error-ex@1.3.2: dependencies: is-arrayish: 0.2.1 + es-module-lexer@1.7.0: {} + + es-toolkit@1.51.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escape-string-regexp@2.0.0: {} + escape-string-regexp@4.0.0: {} - fast-glob@3.3.3: + estree-walker@3.0.3: dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} fast-levenshtein@3.0.0: dependencies: @@ -2689,10 +3623,6 @@ snapshots: fastest-levenshtein@1.0.16: {} - fastq@1.19.1: - dependencies: - reusify: 1.1.0 - fdir@6.4.6(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -2720,33 +3650,18 @@ snapshots: fsevents@2.3.3: optional: true + get-east-asian-width@1.6.0: {} + get-package-type@0.1.0: {} get-stdin@9.0.0: {} get-stream@6.0.1: {} - get-tsconfig@4.10.1: - dependencies: - resolve-pkg-maps: 1.0.0 - git-hooks-list@3.2.0: {} github-slugger@2.0.0: {} - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - - globby@11.1.0: - dependencies: - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.3.3 - ignore: 5.3.2 - merge2: 1.4.1 - slash: 3.0.0 - got@13.0.0: dependencies: '@sindresorhus/is': 5.6.0 @@ -2798,27 +3713,62 @@ snapshots: dependencies: safer-buffer: 2.1.2 - ignore@5.3.2: {} - indent-string@4.0.0: {} + indent-string@5.0.0: {} + ini@1.3.8: {} - is-arrayish@0.2.1: {} + ink-testing-library@4.0.0(@types/react@18.3.31): + optionalDependencies: + '@types/react': 18.3.31 + + ink@5.2.1(@types/react@18.3.31)(react@18.3.1): + dependencies: + '@alcalzone/ansi-tokenize': 0.1.3 + ansi-escapes: 7.3.0 + ansi-styles: 6.2.3 + auto-bind: 5.0.1 + chalk: 5.6.2 + cli-boxes: 3.0.0 + cli-cursor: 4.0.0 + cli-truncate: 4.0.0 + code-excerpt: 4.0.0 + es-toolkit: 1.51.0 + indent-string: 5.0.0 + is-in-ci: 1.0.0 + patch-console: 2.0.0 + react: 18.3.1 + react-reconciler: 0.29.2(react@18.3.1) + scheduler: 0.23.2 + signal-exit: 3.0.7 + slice-ansi: 7.1.2 + stack-utils: 2.0.6 + string-width: 7.2.0 + type-fest: 4.41.0 + widest-line: 5.0.0 + wrap-ansi: 9.0.2 + ws: 8.21.3 + yoga-layout: 3.2.1 + optionalDependencies: + '@types/react': 18.3.31 + transitivePeerDependencies: + - bufferutil + - utf-8-validate - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.3.0 + is-arrayish@0.2.1: {} is-docker@2.2.1: {} - is-extglob@2.1.1: {} - is-fullwidth-code-point@3.0.0: {} - is-glob@4.0.3: + is-fullwidth-code-point@4.0.0: {} + + is-fullwidth-code-point@5.1.0: dependencies: - is-extglob: 2.1.1 + get-east-asian-width: 1.6.0 + + is-in-ci@1.0.0: {} is-number@7.0.0: {} @@ -2838,12 +3788,12 @@ snapshots: filelist: 1.0.4 picocolors: 1.1.1 + js-tokens@4.0.0: {} + json-buffer@3.0.1: {} json-parse-better-errors@1.0.2: {} - json5@2.2.3: {} - jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 @@ -2856,6 +3806,12 @@ snapshots: lodash@4.17.21: {} + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + loupe@3.2.1: {} + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -2864,15 +3820,17 @@ snapshots: lru-cache@10.4.3: {} - make-error@1.3.6: {} - - merge2@1.4.1: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 micromatch@4.0.8: dependencies: braces: 3.0.3 picomatch: 2.3.1 + mimic-fn@2.1.0: {} + mimic-response@3.1.0: {} mimic-response@4.0.0: {} @@ -2885,15 +3843,13 @@ snapshots: dependencies: brace-expansion: 2.0.2 - minimist@1.2.8: {} - ms@2.1.3: {} mute-stream@1.0.0: {} mute-stream@2.0.0: {} - mylas@2.1.13: {} + nanoid@3.3.18: {} no-case@3.0.4: dependencies: @@ -2906,8 +3862,6 @@ snapshots: semver: 7.7.2 validate-npm-package-license: 3.0.4 - normalize-path@3.0.0: {} - normalize-url@8.0.2: {} oclif@4.22.12(@types/node@18.19.122): @@ -2941,6 +3895,10 @@ snapshots: - aws-crt - supports-color + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + p-cancelable@3.0.0: {} param-case@3.0.4: @@ -2958,12 +3916,16 @@ snapshots: no-case: 3.0.4 tslib: 2.8.1 + patch-console@2.0.0: {} + path-case@3.0.4: dependencies: dot-case: 3.0.4 tslib: 2.8.1 - path-type@4.0.0: {} + pathe@1.1.2: {} + + pathval@2.0.1: {} picocolors@1.1.1: {} @@ -2971,21 +3933,25 @@ snapshots: picomatch@4.0.3: {} - plimit-lit@1.6.1: + postcss@8.5.26: dependencies: - queue-lit: 1.5.2 + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 proto-list@1.2.4: {} - queue-lit@1.5.2: {} - - queue-microtask@1.2.3: {} - quick-lru@5.1.1: {} - readdirp@3.6.0: + react-reconciler@0.29.2(react@18.3.1): dependencies: - picomatch: 2.3.1 + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 registry-auth-token@5.1.0: dependencies: @@ -2993,24 +3959,57 @@ snapshots: resolve-alpn@1.2.1: {} - resolve-pkg-maps@1.0.0: {} - responselike@3.0.0: dependencies: lowercase-keys: 3.0.0 - retry@0.13.1: {} + restore-cursor@4.0.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 - reusify@1.1.0: {} + retry@0.13.1: {} - run-parallel@1.2.0: + rollup@4.62.4: dependencies: - queue-microtask: 1.2.3 + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.62.4 + '@rollup/rollup-android-arm64': 4.62.4 + '@rollup/rollup-darwin-arm64': 4.62.4 + '@rollup/rollup-darwin-x64': 4.62.4 + '@rollup/rollup-freebsd-arm64': 4.62.4 + '@rollup/rollup-freebsd-x64': 4.62.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.4 + '@rollup/rollup-linux-arm-musleabihf': 4.62.4 + '@rollup/rollup-linux-arm64-gnu': 4.62.4 + '@rollup/rollup-linux-arm64-musl': 4.62.4 + '@rollup/rollup-linux-loong64-gnu': 4.62.4 + '@rollup/rollup-linux-loong64-musl': 4.62.4 + '@rollup/rollup-linux-ppc64-gnu': 4.62.4 + '@rollup/rollup-linux-ppc64-musl': 4.62.4 + '@rollup/rollup-linux-riscv64-gnu': 4.62.4 + '@rollup/rollup-linux-riscv64-musl': 4.62.4 + '@rollup/rollup-linux-s390x-gnu': 4.62.4 + '@rollup/rollup-linux-x64-gnu': 4.62.4 + '@rollup/rollup-linux-x64-musl': 4.62.4 + '@rollup/rollup-openbsd-x64': 4.62.4 + '@rollup/rollup-openharmony-arm64': 4.62.4 + '@rollup/rollup-win32-arm64-msvc': 4.62.4 + '@rollup/rollup-win32-ia32-msvc': 4.62.4 + '@rollup/rollup-win32-x64-gnu': 4.62.4 + '@rollup/rollup-win32-x64-msvc': 4.62.4 + fsevents: 2.3.3 safe-buffer@5.2.1: {} safer-buffer@2.1.2: {} + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + semver@7.7.2: {} sentence-case@3.0.4: @@ -3019,9 +4018,21 @@ snapshots: tslib: 2.8.1 upper-case-first: 2.0.2 + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} - slash@3.0.0: {} + slice-ansi@5.0.0: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 4.0.0 + + slice-ansi@7.1.2: + dependencies: + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 snake-case@3.0.4: dependencies: @@ -3041,6 +4052,8 @@ snapshots: sort-object-keys: 1.1.3 tinyglobby: 0.2.14 + source-map-js@1.2.1: {} + spdx-correct@3.2.0: dependencies: spdx-expression-parse: 3.0.1 @@ -3055,17 +4068,33 @@ snapshots: spdx-license-ids@3.0.22: {} + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + stackback@0.0.2: {} + + std-env@3.10.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 - strip-bom@3.0.0: {} + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.3.0 strnum@2.1.1: {} @@ -3075,57 +4104,41 @@ snapshots: tiny-jsonc@1.0.2: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.14: dependencies: fdir: 6.4.6(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 - ts-node@10.9.2(@types/node@18.19.122)(typescript@5.9.2): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.11 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 18.19.122 - acorn: 8.15.0 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.9.2 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - - tsc-alias@1.8.16: - dependencies: - chokidar: 3.6.0 - commander: 9.5.0 - get-tsconfig: 4.10.1 - globby: 11.1.0 - mylas: 2.1.13 - normalize-path: 3.0.0 - plimit-lit: 1.6.1 - - tsconfig-paths@4.2.0: - dependencies: - json5: 2.2.3 - minimist: 1.2.8 - strip-bom: 3.0.0 - tslib@2.8.1: {} + tsx@4.23.12: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 type-fest@0.21.3: {} + type-fest@4.41.0: {} + typescript@5.9.2: {} undici-types@5.26.5: {} @@ -3144,8 +4157,6 @@ snapshots: uuid@9.0.1: {} - v8-compile-cache-lib@3.0.1: {} - validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -3153,10 +4164,81 @@ snapshots: validate-npm-package-name@5.0.1: {} + vite-node@2.1.9(@types/node@18.19.122): + dependencies: + cac: 6.7.14 + debug: 4.4.1(supports-color@8.1.1) + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@18.19.122) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@18.19.122): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.26 + rollup: 4.62.4 + optionalDependencies: + '@types/node': 18.19.122 + fsevents: 2.3.3 + + vitest@2.1.9(@types/node@18.19.122): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@18.19.122)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.1(supports-color@8.1.1) + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@18.19.122) + vite-node: 2.1.9(@types/node@18.19.122) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 18.19.122 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + widest-line@3.1.0: dependencies: string-width: 4.2.3 + widest-line@5.0.0: + dependencies: + string-width: 7.2.0 + wordwrap@1.0.0: {} wrap-ansi@6.2.0: @@ -3171,6 +4253,20 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - yn@3.1.1: {} + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + ws@8.21.3: {} yoctocolors-cjs@2.1.2: {} + + yoga-layout@3.2.1: {} + + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8a4a6d9..9dc4919 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,6 @@ packages: - "packages/*" +ignoredBuiltDependencies: + - '@pnpm/exe' + - esbuild From 2cb2e9edce6d4b6b0e68059dd043d1eb38dfe349 Mon Sep 17 00:00:00 2001 From: d4rm5 Date: Thu, 20 Aug 2026 15:09:58 -0300 Subject: [PATCH 2/7] fix(containerfile): stop root and vscode sharing ~/.npm in the node install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image pins ENV HOME=/home/vscode for every stage, so the `USER root` node step ran with the same HOME as the unprivileged user. Upstream's devcontainers node install.sh runs its npm-version block as root but wraps the yarn/pnpm steps in `su vscode`, so root seeded root-owned entries in the shared /home/vscode/.npm/_cacache and the later su-vscode npm call died with EACCES. The script runs under `set -e`, so it aborted there and the chown meant to repair the cache never executed — the whole build failed at the node step for every environment that pulls Node. Give root its own HOME instead. The script is fetched to a file first because an env prefix on `curl ... | bash` binds to curl, never to the interpreter on the far side of the pipe. Also list $PNPM_HOME/bin on PATH alongside $PNPM_HOME. pnpm links global bins into $PNPM_HOME itself on v10 and into $PNPM_HOME/bin on v11, but its preflight check refuses `pnpm install -g` unless $PNPM_HOME/bin is present, which broke the hardhat install once the node step started succeeding. Verified end to end on Apple Containers: build exits 0, failedTools is empty, and node/pnpm/hardhat/claude all run inside the container. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0189PbnZFz2Ga2LxY2TLyVK2 --- packages/core/src/domain/install-commands.ts | 17 +++++++++++++---- .../core/test/unit/install-commands.test.ts | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/core/src/domain/install-commands.ts b/packages/core/src/domain/install-commands.ts index 4e03b42..98e6557 100644 --- a/packages/core/src/domain/install-commands.ts +++ b/packages/core/src/domain/install-commands.ts @@ -35,12 +35,21 @@ RUN git clone https://github.com/asdf-vm/asdf.git $HOME/.asdf --branch v0.15.0 & `, node: ` USER root -# Install nvm, yarn, npm, pnpm -RUN curl -o- https://raw.githubusercontent.com/devcontainers/features/main/src/node/install.sh | bash -RUN chown -R vscode:vscode \${HOME}/.npm +# Install nvm, yarn, npm, pnpm. +# HOME is pinned to /home/vscode image-wide, so root and vscode would share +# ~/.npm. Upstream's script runs the npm-version step as root but the yarn/pnpm +# steps via 'su vscode', so the shared cache ends up part root-owned and the +# 'su vscode' npm call dies with EACCES (set -e then aborts the whole script). +# Give root its own HOME: the script is fetched to a file first because an env +# prefix on 'curl ... | bash' would only apply to curl, never to the script. +RUN curl -fsSL -o /tmp/node-install.sh https://raw.githubusercontent.com/devcontainers/features/main/src/node/install.sh && HOME=/root bash /tmp/node-install.sh && rm -f /tmp/node-install.sh +RUN mkdir -p \${HOME}/.npm && chown -R vscode:vscode \${HOME}/.npm USER vscode ENV PNPM_HOME=\${HOME}/.local/share/pnpm -ENV PATH=\${PATH}:\${PNPM_HOME} +# pnpm drops global bins straight into \$PNPM_HOME, but its own preflight check +# refuses to run a global install unless \$PNPM_HOME/bin is on PATH too, so both +# have to be listed or 'pnpm install -g' aborts with "not in PATH". +ENV PATH=\${PATH}:\${PNPM_HOME}:\${PNPM_HOME}/bin `, // Frameworks foundry: ` diff --git a/packages/core/test/unit/install-commands.test.ts b/packages/core/test/unit/install-commands.test.ts index 358f230..99c3566 100644 --- a/packages/core/test/unit/install-commands.test.ts +++ b/packages/core/test/unit/install-commands.test.ts @@ -17,4 +17,22 @@ describe('install snippets', () => { it('aderyn keeps its legitimate command fallback (#13)', () => { expect(INSTALL_COMMANDS.aderyn).toContain('|| cyfrinup') }) + + it("node runs the devcontainers install.sh under root's own HOME", () => { + // The image pins ENV HOME=/home/vscode for every user. Upstream's devcontainers + // node install.sh runs the npm-version step as root and the yarn/pnpm steps via + // `su vscode`; a shared ~/.npm therefore ends up part root-owned and the + // `su vscode` npm call dies with EACCES, aborting the script under `set -e`. + expect(INSTALL_COMMANDS.node).toContain('HOME=/root bash /tmp/node-install.sh') + // The env prefix must sit on the interpreter, not on the fetch: an env prefix on + // `curl ... | bash` applies only to curl and never reaches the script. + expect(INSTALL_COMMANDS.node).not.toMatch(/HOME=\S+ curl/) + expect(INSTALL_COMMANDS.node).not.toMatch(/install\.sh\s*\|\s*bash/) + }) + + it('node puts both $PNPM_HOME and $PNPM_HOME/bin on PATH', () => { + // pnpm links global bins into $PNPM_HOME itself, but refuses to run + // `pnpm install -g` unless $PNPM_HOME/bin is also on PATH. + expect(INSTALL_COMMANDS.node).toContain('ENV PATH=${PATH}:${PNPM_HOME}:${PNPM_HOME}/bin') + }) }) From c76bd01bcd930de1cc8918a3010f3368c0e05241 Mon Sep 17 00:00:00 2001 From: d4rm5 Date: Thu, 20 Aug 2026 15:13:07 -0300 Subject: [PATCH 3/7] feat(apple-container): diagnose a builder with no DNS instead of leaking apt noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When nothing serves DNS on the vmnet gateway, every `RUN apt-get update` inside an Apple Containers build ends in `Temporary failure resolving 'deb.debian.org'`. That reads like a broken Containerfile and is actually a host port-53 conflict: Apple's gateway resolver does a wildcard IPv4 bind, which macOS refuses with EADDRINUSE once anything holds a specific 127.0.0.0/8 address on :53 — a local DNS-over-HTTPS proxy, for instance. The IPv6 wildcard bind still succeeds, so `*:53` looks healthy in netstat while IPv4 has no listener at all. Upstream: apple/container#402. Probe for exactly that shape, but only after `container build` has already failed, so healthy builds pay nothing and no preflight can fail closed on a working host. The diagnosis needs both halves — a builder with no resolvers of its own AND no IPv4 listener for the gateway — and fails open, leaving the original build error intact whenever the probe output is unparseable. Raise it as E_ENGINE_DNS (exit 10), separate from E_ENGINE_UNAVAILABLE: the engine is running fine, and the fix is a host network change, not starting a daemon. The message names the processes holding :53 and the `container builder start --dns ` workaround. Also correct the driver's `dns` capability note, which claimed support was "limited" without saying how. `--dns` is genuinely honored by `container run`, so `secure-dns` stays a real control and the capability stays `caveated`; it is the build path that silently ignores the flag, because build steps run inside the shared `buildkit` container and inherit its resolvers. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0189PbnZFz2Ga2LxY2TLyVK2 --- README.md | 2 +- packages/core/skill/SKILL.md | 1 + packages/core/src/engine/dns-preflight.ts | 198 +++++++++++++ .../src/engine/drivers/apple-container.ts | 32 ++- packages/core/src/errors.ts | 14 + .../core/test/unit/apple-build-dns.test.ts | 264 ++++++++++++++++++ 6 files changed, 509 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/engine/dns-preflight.ts create mode 100644 packages/core/test/unit/apple-build-dns.test.ts diff --git a/README.md b/README.md index 7c8ab00..b03205d 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ migration — v1 configs are not read. Coming from a VS Code Dev Containers work | OrbStack | macOS | Docker-compatible; auto-preferred on macOS | | Podman | all | Rootless; uid-mapped tmpfs auto-uses `--userns=keep-id` | | Lima (nerdctl) | macOS/Linux | AppArmor/sysctl depend on the guest VM | -| Apple Containers | macOS 15+ (arm64) | VM-isolated. Applies `--cap-drop`; **drops** read-only rootfs, tmpfs options, no-new-privileges, AppArmor and seccomp. Does **not** enforce network isolation (`--profile airgapped` stays networked unless you pass `--strict`) | +| Apple Containers | macOS 15+ (arm64) | VM-isolated. Applies `--cap-drop`; **drops** read-only rootfs, tmpfs options, no-new-privileges, AppArmor and seccomp. Does **not** enforce network isolation (`--profile airgapped` stays networked unless you pass `--strict`). `--dns` works for `run` but not for `build` — build steps inherit the shared `buildkit` builder's resolvers (`container builder start --dns `); dcw reports a builder with no DNS as `E_ENGINE_DNS` (exit 10) instead of a raw apt error | `dcw engines` shows live availability + per-engine hardening trade-offs. diff --git a/packages/core/skill/SKILL.md b/packages/core/skill/SKILL.md index 21fc401..83246a8 100644 --- a/packages/core/skill/SKILL.md +++ b/packages/core/skill/SKILL.md @@ -53,6 +53,7 @@ Exit codes are a stable contract — branch on them rather than parsing message | 7 | `--strict`: hardening dropped or unenforced | `E_STRICT_HARDENING` | | 8 | Environment / container not found | `E_NOT_FOUND` | | 9 | Invalid input (bad name, profile, git URL, …) | `E_VALIDATION` | +| 10 | Engine is running but its DNS is broken (e.g. the Apple Containers builder cannot resolve) | `E_ENGINE_DNS` | Under `--json`, **every** failure — including usage errors — prints a single envelope on stdout and nothing else, so stdout is always parseable: diff --git a/packages/core/src/engine/dns-preflight.ts b/packages/core/src/engine/dns-preflight.ts new file mode 100644 index 0000000..8d06f17 --- /dev/null +++ b/packages/core/src/engine/dns-preflight.ts @@ -0,0 +1,198 @@ +import { capture } from './exec.js' + +/** + * Diagnosis for the single worst failure mode on Apple Containers: a build whose + * `RUN apt-get update` dies with `Temporary failure resolving 'deb.debian.org'`. + * + * Why this needs its own diagnosis instead of "just pass --dns": + * + * - `container build` does NOT execute in an ephemeral network namespace the way + * `docker build` does. Every build step runs inside the long-lived, shared + * `buildkit` container, so the build resolves through THAT container's + * /etc/resolv.conf. `container build --dns ` is accepted by the argument + * parser and then has no effect — verified against `container` CLI 1.0.0. + * Emitting it would be exactly the "flag that does nothing" this codebase + * refuses to emit elsewhere (see the capability notes in apple-container.ts). + * - The builder's resolvers are fixed at `container builder start` time, and + * default to the container network's gateway (192.168.64.1). + * - Nothing in dcw's process can serve DNS on that gateway. Apple's gateway + * resolver binds port 53 on the IPv4 wildcard, so ANY host process that already + * holds :53 on a specific IPv4 address (Cloudflare WARP's local DoH proxy on + * 127.0.2.2/127.0.2.3, dnsmasq, Tailscale, zScaler, …) makes that bind fail and + * silently leaves the gateway with no resolver at all — apple/container#402. + * + * So dcw cannot fix this; it can only stop hiding it. The probe runs ONLY after a + * build has already failed (zero cost on the happy path, and no chance of a false + * positive stopping a healthy build), and it fails open: anything unparseable or + * unexpected yields `null` so the caller re-throws the real build error. + */ + +/** A host socket bound to port 53, as reported by `netstat -an -p udp`. */ +export interface Port53Listener { + family: 'inet' | 'inet6' + /** Bound address, or '*' for the wildcard. */ + address: string +} + +/** + * Parse `netstat -an -p udp` output down to the port-53 bindings. + * + * macOS rows are `proto Recv-Q Send-Q Local-Address Foreign-Address`, with the + * local address written as `.` (`*.53`, `127.0.2.2.53`, + * `fe80::1%lo0.53`) — hence the split on the LAST dot, not the first. + */ +export function parsePort53Listeners(netstatOutput: string): Port53Listener[] { + const out: Port53Listener[] = [] + for (const line of netstatOutput.split('\n')) { + const cols = line.trim().split(/\s+/) + const proto = cols[0] + const local = cols[3] + if (!local) continue + if (proto !== 'udp4' && proto !== 'udp6') continue + const dot = local.lastIndexOf('.') + if (dot < 0) continue + if (local.slice(dot + 1) !== '53') continue + out.push({ family: proto === 'udp4' ? 'inet' : 'inet6', address: local.slice(0, dot) }) + } + return out +} + +const WILDCARD_ADDRS = new Set(['*', '0.0.0.0']) + +/** + * True when no host socket can be answering DNS at the container gateway. + * + * IPv4 only: the guest's resolv.conf carries the IPv4 gateway, so an IPv6 + * wildcard bind (which succeeds even when the IPv4 one is refused — the usual + * shape of this bug) proves nothing. + */ +export function gatewayDnsUnavailable(listeners: Port53Listener[], gatewayIpv4: string): boolean { + return !listeners.some( + (l) => l.family === 'inet' && (WILDCARD_ADDRS.has(l.address) || l.address === gatewayIpv4), + ) +} + +/** IPv4 port-53 binders that are not the gateway — the processes that block the wildcard bind. */ +export function conflictingPort53Binders(listeners: Port53Listener[], gatewayIpv4: string): string[] { + const seen = new Set() + for (const l of listeners) { + if (l.family !== 'inet') continue + if (WILDCARD_ADDRS.has(l.address) || l.address === gatewayIpv4) continue + seen.add(`${l.address}:53`) + } + return [...seen] +} + +export interface AppleDnsProbe { + /** Explicit resolvers the `buildkit` container was started with (empty = inherit the gateway). */ + builderNameservers: string[] + gatewayIpv4: string + listeners: Port53Listener[] +} + +export interface AppleDnsDiagnosis { + /** True when the builder resolves through a gateway that nothing is serving. */ + blocked: boolean + gatewayIpv4: string + /** Other IPv4 sockets holding port 53, which is why the gateway resolver could not bind. */ + conflicting: string[] +} + +/** + * Decide whether a failed Apple Containers build was starved of DNS. + * + * Blocked requires BOTH halves: the builder has no resolver of its own (or was + * pointed straight at the gateway), AND nothing is serving DNS on that gateway. + * A builder started with `--dns 1.1.1.1` is never reported as blocked, so a + * genuine compile error in a Containerfile still surfaces as itself. + */ +export function diagnoseAppleBuildDns(probe: AppleDnsProbe): AppleDnsDiagnosis { + const usesGateway = + probe.builderNameservers.length === 0 || probe.builderNameservers.includes(probe.gatewayIpv4) + return { + blocked: usesGateway && gatewayDnsUnavailable(probe.listeners, probe.gatewayIpv4), + gatewayIpv4: probe.gatewayIpv4, + conflicting: conflictingPort53Binders(probe.listeners, probe.gatewayIpv4), + } +} + +/** Public resolvers suggested in the remediation (same pair `secure-dns` uses). */ +const SUGGESTED_RESOLVERS = ['1.1.1.1', '1.0.0.1'] + +/** The actionable error text. Keep the exact commands copy-pasteable. */ +export function appleBuildDnsMessage(diag: AppleDnsDiagnosis): string { + const conflict = + diag.conflicting.length > 0 + ? ` Something else on this host already holds port 53 (${diag.conflicting.join(', ')}) — ` + + 'a local DNS proxy such as Cloudflare WARP, dnsmasq, Tailscale or zScaler — which stops ' + + "Apple's gateway resolver from binding (apple/container#402)." + : ' No host process is bound to port 53 for the container network.' + const dnsArgs = SUGGESTED_RESOLVERS.map((s) => `--dns ${s}`).join(' ') + return ( + 'Apple Containers build failed and the builder has no working DNS.\n' + + `Build steps run inside the shared \`buildkit\` container, which resolves through the container ` + + `network gateway ${diag.gatewayIpv4}.${conflict}\n` + + '`container build --dns` is accepted but ignored — the builder\'s resolvers are fixed when it ' + + 'starts. Point the builder at public resolvers instead, then rebuild:\n' + + ' container builder stop\n' + + ` container builder start ${dnsArgs}` + ) +} + +/** Read `configuration.dns.nameservers` out of `container inspect ` JSON. */ +export function parseBuilderNameservers(stdout: string): string[] | null { + let rows: unknown + try { + rows = JSON.parse(stdout) + } catch { + return null + } + if (!Array.isArray(rows) || rows.length === 0) return null + const cfg = ((rows[0] as Record).configuration ?? {}) as Record + const dns = cfg.dns + if (dns === null || dns === undefined) return [] + if (typeof dns !== 'object') return null + const servers = (dns as Record).nameservers + if (servers === null || servers === undefined) return [] + if (!Array.isArray(servers)) return null + return servers.map((s) => String(s)) +} + +/** Read `status.ipv4Gateway` out of `container network inspect ` JSON. */ +export function parseNetworkGateway(stdout: string): string | null { + let rows: unknown + try { + rows = JSON.parse(stdout) + } catch { + return null + } + if (!Array.isArray(rows) || rows.length === 0) return null + const status = ((rows[0] as Record).status ?? {}) as Record + const gw = status.ipv4Gateway + return typeof gw === 'string' && gw.length > 0 ? gw : null +} + +/** + * Probe the host + builder read-only. Returns `null` whenever the picture is + * incomplete — the caller must then re-throw the original build error rather + * than blame DNS for something it cannot prove. + */ +export async function probeAppleBuildDns(bin = 'container'): Promise { + const [inspect, network, netstat] = await Promise.all([ + capture(bin, ['inspect', 'buildkit']), + capture(bin, ['network', 'inspect', 'default']), + capture('netstat', ['-an', '-p', 'udp']), + ]) + if (inspect.code !== 0 || network.code !== 0 || netstat.code !== 0) return null + + const builderNameservers = parseBuilderNameservers(inspect.stdout) + const gatewayIpv4 = parseNetworkGateway(network.stdout) + if (builderNameservers === null || gatewayIpv4 === null) return null + + const listeners = parsePort53Listeners(netstat.stdout) + // An empty parse means netstat printed something we do not understand; treating + // that as "nothing is listening" would accuse a healthy host. + if (listeners.length === 0) return null + + return diagnoseAppleBuildDns({ builderNameservers, gatewayIpv4, listeners }) +} diff --git a/packages/core/src/engine/drivers/apple-container.ts b/packages/core/src/engine/drivers/apple-container.ts index 26fe610..9c435c7 100644 --- a/packages/core/src/engine/drivers/apple-container.ts +++ b/packages/core/src/engine/drivers/apple-container.ts @@ -1,5 +1,8 @@ +import { EngineDnsError } from '../../errors.js' +import { appleBuildDnsMessage, probeAppleBuildDns } from '../dns-preflight.js' import { capture } from '../exec.js' import type { + BuildSpec, ContainerInfo, DetectResult, EngineCapabilities, @@ -46,7 +49,15 @@ export class AppleContainerDriver extends CliDriver { note: 'The container CLI does not honor Docker --network=none; air-gap is not enforced.', }, sysctl: { support: 'unsupported', note: 'sysctl tuning is not exposed.' }, - dns: { support: 'caveated', note: 'DNS configuration support is limited.' }, + // `--dns` IS honored by `container run` (verified: a run with --dns 1.1.1.1 + // resolves where the default gateway resolver does not), so `secure-dns` is a + // real control here and must not be dropped. The caveat is the BUILD path: + // `container build --dns` parses and then does nothing, because build steps + // execute inside the shared `buildkit` container and inherit ITS resolvers. + dns: { + support: 'caveated', + note: '`--dns` is honored by `container run`, but NOT by `container build`: build steps execute inside the shared `buildkit` container and inherit its resolvers, which are fixed by `container builder start --dns `.', + }, memoryLimit: { support: 'caveated', note: 'Memory limits use a different syntax and granularity.' }, cpuLimit: { support: 'caveated', note: 'CPU limits use a different syntax and granularity.' }, userNamespaces: { support: 'unsupported', note: 'User-namespace remapping is not applicable (VM-isolated).' }, @@ -59,6 +70,25 @@ export class AppleContainerDriver extends CliDriver { }) } + /** + * Build, and translate the one failure users cannot debug on their own. + * + * A builder with no DNS turns every `RUN apt-get update` into a wall of apt + * output ending in `Temporary failure resolving 'deb.debian.org'` — which looks + * like a broken Containerfile and is actually a host port-53 conflict. The probe + * runs only after the build has already failed, so healthy builds pay nothing, + * and it fails open: no confident diagnosis means the original error survives. + */ + override async build(spec: BuildSpec): Promise<{ imageId: string }> { + try { + return await super.build(spec) + } catch (err) { + const diag = await probeAppleBuildDns(this.bin).catch(() => null) + if (diag?.blocked) throw new EngineDnsError(appleBuildDnsMessage(diag)) + throw err + } + } + override async detect(): Promise { const ver = await capture('container', ['--version']) if (ver.spawnError) { diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 7a9030c..3cd7fa0 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -10,6 +10,7 @@ export enum ExitCode { StrictHardening = 7, NotFound = 8, ValidationError = 9, + EngineDns = 10, } /** Base error carrying a deterministic exit code and a stable machine `code`. */ @@ -46,6 +47,19 @@ export class EngineUnavailableError extends DcwError { } } +/** + * The engine is installed and running, but its DNS plumbing is broken, so + * network-dependent work inside it cannot resolve names. Distinct from + * EngineUnavailableError (engine not running at all): the remediation is a host + * network/daemon fix, not "install or start the engine". + */ +export class EngineDnsError extends DcwError { + constructor(message: string) { + super(message, ExitCode.EngineDns, 'E_ENGINE_DNS') + this.name = 'EngineDnsError' + } +} + export class StrictHardeningError extends DcwError { constructor(message: string) { super(message, ExitCode.StrictHardening, 'E_STRICT_HARDENING') diff --git a/packages/core/test/unit/apple-build-dns.test.ts b/packages/core/test/unit/apple-build-dns.test.ts new file mode 100644 index 0000000..c215d0b --- /dev/null +++ b/packages/core/test/unit/apple-build-dns.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { EngineDnsError, ExitCode } from '../../src/errors.js' +import type { CaptureResult } from '../../src/engine/exec.js' + +// The exec layer is the only I/O these paths do; script it per test so the probe +// can be exercised without touching the host or the `container` CLI. +const capture = vi.fn(async (_bin: string, _args: string[]): Promise => ok('')) +const inherit = vi.fn(async (): Promise => 1) + +vi.mock('../../src/engine/exec.js', async (orig) => { + const actual = await orig() + return { ...actual, capture: (b: string, a: string[]) => capture(b, a), inherit: () => inherit() } +}) + +const { + appleBuildDnsMessage, + conflictingPort53Binders, + diagnoseAppleBuildDns, + gatewayDnsUnavailable, + parseBuilderNameservers, + parseNetworkGateway, + parsePort53Listeners, + probeAppleBuildDns, +} = await import('../../src/engine/dns-preflight.js') +const { AppleContainerDriver } = await import('../../src/engine/drivers/apple-container.js') + +const ok = (stdout: string): CaptureResult => ({ code: 0, stdout, stderr: '', spawnError: false }) + +const GW = '192.168.64.1' + +// Captured verbatim from `netstat -an -p udp` on a macOS 26 host where Cloudflare +// WARP's local DoH proxy holds :53 on two loopback addresses. Note the asymmetry +// that is the signature of apple/container#402: the IPv6 wildcard bind succeeded, +// the IPv4 wildcard bind did not, so the gateway has no v4 resolver. +const BROKEN_NETSTAT = `Active Internet connections (including servers) +Proto Recv-Q Send-Q Local Address Foreign Address (state) +udp6 0 0 *.53 *.* +udp4 0 0 127.0.2.3.53 *.* +udp4 0 0 127.0.2.2.53 *.* +udp4 0 0 *.5353 *.* +` + +const HEALTHY_NETSTAT = `Active Internet connections (including servers) +Proto Recv-Q Send-Q Local Address Foreign Address (state) +udp6 0 0 *.53 *.* +udp4 0 0 *.53 *.* +` + +describe('parsePort53Listeners', () => { + it('keeps only port 53 and splits the address off the LAST dot', () => { + expect(parsePort53Listeners(BROKEN_NETSTAT)).toEqual([ + { family: 'inet6', address: '*' }, + { family: 'inet', address: '127.0.2.3' }, + { family: 'inet', address: '127.0.2.2' }, + ]) + }) + + it('does not mistake port 5353 (mDNS) for port 53', () => { + const addrs = parsePort53Listeners(BROKEN_NETSTAT).map((l) => l.address) + expect(addrs).not.toContain('*.5353') + expect(parsePort53Listeners(BROKEN_NETSTAT)).toHaveLength(3) + }) + + it('handles scoped IPv6 local addresses', () => { + const rows = 'udp6 0 0 fe80::1%lo0.53 *.*\n' + expect(parsePort53Listeners(rows)).toEqual([{ family: 'inet6', address: 'fe80::1%lo0' }]) + }) + + it('ignores tcp rows and header noise', () => { + const rows = 'Proto Recv-Q Send-Q Local Address\ntcp4 0 0 127.0.2.2.53 *.* LISTEN\n' + expect(parsePort53Listeners(rows)).toEqual([]) + }) +}) + +describe('gatewayDnsUnavailable', () => { + it('is true when only specific loopback addresses hold IPv4 :53', () => { + expect(gatewayDnsUnavailable(parsePort53Listeners(BROKEN_NETSTAT), GW)).toBe(true) + }) + + it('is false once something holds the IPv4 wildcard', () => { + expect(gatewayDnsUnavailable(parsePort53Listeners(HEALTHY_NETSTAT), GW)).toBe(false) + }) + + it('is false when a resolver is bound to the gateway address itself', () => { + expect(gatewayDnsUnavailable([{ family: 'inet', address: GW }], GW)).toBe(false) + }) + + it('ignores the IPv6 wildcard — the guest resolves via the IPv4 gateway', () => { + // This is the exact trap: `*:53` shows up in netstat and looks healthy, but it + // is udp6 only, so the container's `nameserver 192.168.64.1` still answers nothing. + expect(gatewayDnsUnavailable([{ family: 'inet6', address: '*' }], GW)).toBe(true) + }) +}) + +describe('conflictingPort53Binders', () => { + it('names the IPv4 squatters that blocked the wildcard bind, deduped', () => { + const listeners = [...parsePort53Listeners(BROKEN_NETSTAT), { family: 'inet' as const, address: '127.0.2.2' }] + expect(conflictingPort53Binders(listeners, GW)).toEqual(['127.0.2.3:53', '127.0.2.2:53']) + }) + + it('does not name the gateway or the wildcard as conflicts', () => { + expect(conflictingPort53Binders([{ family: 'inet', address: '*' }, { family: 'inet', address: GW }], GW)).toEqual([]) + }) +}) + +describe('diagnoseAppleBuildDns', () => { + const listeners = parsePort53Listeners(BROKEN_NETSTAT) + + it('blocks when the builder has no resolvers of its own and the gateway is dead', () => { + const d = diagnoseAppleBuildDns({ builderNameservers: [], gatewayIpv4: GW, listeners }) + expect(d.blocked).toBe(true) + expect(d.conflicting).toContain('127.0.2.2:53') + }) + + it('blocks when the builder was pointed explicitly at the dead gateway', () => { + expect(diagnoseAppleBuildDns({ builderNameservers: [GW], gatewayIpv4: GW, listeners }).blocked).toBe(true) + }) + + it('does NOT blame DNS when the builder has its own public resolvers', () => { + // `container builder start --dns 1.1.1.1 --dns 1.0.0.1` — a build failing here + // is a real Containerfile failure and must keep its own error. + const d = diagnoseAppleBuildDns({ builderNameservers: ['1.1.1.1', '1.0.0.1'], gatewayIpv4: GW, listeners }) + expect(d.blocked).toBe(false) + }) + + it('does NOT blame DNS on a host whose gateway resolver is up', () => { + const d = diagnoseAppleBuildDns({ + builderNameservers: [], + gatewayIpv4: GW, + listeners: parsePort53Listeners(HEALTHY_NETSTAT), + }) + expect(d.blocked).toBe(false) + }) +}) + +describe('appleBuildDnsMessage', () => { + it('gives the copy-pasteable builder restart, not a --dns flag that does nothing', () => { + const msg = appleBuildDnsMessage({ blocked: true, gatewayIpv4: GW, conflicting: ['127.0.2.2:53'] }) + expect(msg).toContain('container builder stop') + expect(msg).toContain('container builder start --dns 1.1.1.1 --dns 1.0.0.1') + expect(msg).toContain('`container build --dns` is accepted but ignored') + expect(msg).toContain(GW) + expect(msg).toContain('127.0.2.2:53') + }) + + it('still explains itself when no conflicting binder could be named', () => { + const msg = appleBuildDnsMessage({ blocked: true, gatewayIpv4: GW, conflicting: [] }) + expect(msg).toContain('No host process is bound to port 53') + expect(msg).toContain('container builder start') + }) +}) + +describe('inspect parsers', () => { + it('reads the builder nameservers out of Apple\'s inspect schema', () => { + const json = JSON.stringify([{ configuration: { dns: { nameservers: ['1.1.1.1'], options: [] } } }]) + expect(parseBuilderNameservers(json)).toEqual(['1.1.1.1']) + }) + + it('treats an absent dns block as "inherits the gateway", not as unparseable', () => { + expect(parseBuilderNameservers(JSON.stringify([{ configuration: {} }]))).toEqual([]) + expect(parseBuilderNameservers(JSON.stringify([{ configuration: { dns: {} } }]))).toEqual([]) + }) + + it('returns null (no diagnosis) for shapes it does not recognise', () => { + expect(parseBuilderNameservers('not json')).toBeNull() + expect(parseBuilderNameservers('[]')).toBeNull() + expect(parseBuilderNameservers(JSON.stringify([{ configuration: { dns: { nameservers: 'x' } } }]))).toBeNull() + }) + + it('reads the IPv4 gateway out of `container network inspect`', () => { + const json = JSON.stringify([{ id: 'default', status: { ipv4Gateway: GW, ipv4Subnet: '192.168.64.0/24' } }]) + expect(parseNetworkGateway(json)).toBe(GW) + expect(parseNetworkGateway('[]')).toBeNull() + expect(parseNetworkGateway(JSON.stringify([{ status: {} }]))).toBeNull() + }) +}) + +describe('probeAppleBuildDns / AppleContainerDriver.build', () => { + const INSPECT_NO_DNS = JSON.stringify([{ configuration: { dns: { nameservers: [] } } }]) + const INSPECT_WITH_DNS = JSON.stringify([{ configuration: { dns: { nameservers: ['1.1.1.1'] } } }]) + const NETWORK = JSON.stringify([{ status: { ipv4Gateway: GW } }]) + + function stubHost(inspectJson: string, netstat: string) { + capture.mockImplementation(async (bin, args) => { + if (bin === 'netstat') return ok(netstat) + if (args[0] === 'inspect') return ok(inspectJson) + if (args[0] === 'network') return ok(NETWORK) + // `image inspect --format` after a build — irrelevant here. + return ok('') + }) + } + + beforeEach(() => { + capture.mockReset() + // The build itself always fails; the question is which error the user sees. + inherit.mockReset().mockResolvedValue(1) + }) + + const spec = { containerfilePath: '/tmp/Containerfile', contextDir: '/tmp', tag: 'dcw/demo:latest' } + + it('probes read-only commands only', async () => { + stubHost(INSPECT_NO_DNS, BROKEN_NETSTAT) + await probeAppleBuildDns() + const calls = capture.mock.calls.map(([bin, args]) => `${bin} ${args.join(' ')}`) + expect(calls).toEqual([ + 'container inspect buildkit', + 'container network inspect default', + 'netstat -an -p udp', + ]) + }) + + it('turns a DNS-starved build failure into E_ENGINE_DNS with exit 10', async () => { + stubHost(INSPECT_NO_DNS, BROKEN_NETSTAT) + const err = await new AppleContainerDriver().build(spec).catch((e: unknown) => e) + expect(err).toBeInstanceOf(EngineDnsError) + expect((err as EngineDnsError).code).toBe('E_ENGINE_DNS') + expect((err as EngineDnsError).exitCode).toBe(ExitCode.EngineDns) + expect((err as Error).message).toContain('container builder start --dns') + }) + + it('leaves a genuine build failure alone when the builder has working DNS', async () => { + stubHost(INSPECT_WITH_DNS, BROKEN_NETSTAT) + const err = await new AppleContainerDriver().build(spec).catch((e: unknown) => e) + expect(err).not.toBeInstanceOf(EngineDnsError) + expect((err as Error).message).toContain('build failed') + }) + + it('leaves a genuine build failure alone on a host with a healthy gateway resolver', async () => { + stubHost(INSPECT_NO_DNS, HEALTHY_NETSTAT) + const err = await new AppleContainerDriver().build(spec).catch((e: unknown) => e) + expect(err).not.toBeInstanceOf(EngineDnsError) + }) + + it('fails open when a probe command errors — the real build error survives', async () => { + capture.mockResolvedValue({ code: 1, stdout: '', stderr: 'nope', spawnError: false }) + const err = await new AppleContainerDriver().build(spec).catch((e: unknown) => e) + expect(err).not.toBeInstanceOf(EngineDnsError) + expect(await probeAppleBuildDns()).toBeNull() + }) + + it('never runs the probe when the build succeeds', async () => { + inherit.mockResolvedValue(0) + stubHost(INSPECT_NO_DNS, BROKEN_NETSTAT) + await new AppleContainerDriver().build(spec) + const probed = capture.mock.calls.some(([bin]) => bin === 'netstat') + expect(probed).toBe(false) + }) +}) + +describe('the dns capability note tells the truth about run vs build', () => { + const cap = new AppleContainerDriver().capabilities.dns + + it('does not drop secure-dns — `container run --dns` genuinely works', () => { + expect(cap.support).toBe('caveated') + expect(cap.enforced).not.toBe(false) + }) + + it('names the build path as the gap, instead of "support is limited"', () => { + expect(cap.note).not.toMatch(/support is limited/i) + expect(cap.note).toContain('container build') + expect(cap.note).toContain('buildkit') + expect(cap.note).toContain('container builder start') + }) +}) From 995d1a7f4d984908e5d06d9ce4b6d0cc9b8674c9 Mon Sep 17 00:00:00 2001 From: d4rm5 Date: Thu, 20 Aug 2026 15:17:45 -0300 Subject: [PATCH 4/7] fix(containerfile): put nvm's bin dir on the image PATH, not just a shell rc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream's devcontainers node install.sh puts node under /usr/local/share/nvm and exports it only by appending an `nvm.sh` snippet to ~/.zshrc, so node, npm, npx, pnpm and any npm -g bin (claude included) were reachable from `dcw shell` but invisible to `dcw exec` — which runs the command directly, not through an interactive shell. `dcw exec env -- node -v` failed with "failed to find target executable node", and every Node-based tool with a shebang wrapper died with "exec: node: not found", including hardhat, the framework the environment exists to run. The script sets NVM_SYMLINK_CURRENT and keeps a $NVM_DIR/current symlink precisely so a Dockerfile can put it on PATH; do that. Pinning the symlink rather than a versioned directory keeps `nvm alias default` working inside the container. NVM_DIR is declared in the node snippet rather than the base layer so node-less images don't export a dangling path. While here, stop UV_INSTALL re-appending USR_LOCAL_BIN, LOCAL_BIN and PNPM_HOME to a PATH that already ended in all three, which was tripling those entries in every image. Verified from a non-interactive exec on the rebuilt image: node v24.19.0, npm 11.17.0, pnpm 10.18.0, hardhat 3.13.0, claude 2.1.237. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0189PbnZFz2Ga2LxY2TLyVK2 --- packages/core/src/containerfile/base.ts | 3 ++- packages/core/src/domain/install-commands.ts | 19 ++++++++++----- .../__snapshots__/containerfile.test.ts.snap | 3 ++- .../core/test/unit/install-commands.test.ts | 24 +++++++++++++++---- 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/packages/core/src/containerfile/base.ts b/packages/core/src/containerfile/base.ts index ce590db..4a0875f 100644 --- a/packages/core/src/containerfile/base.ts +++ b/packages/core/src/containerfile/base.ts @@ -78,7 +78,8 @@ export const UV_INSTALL = [ '# Install uv', 'RUN curl -LsSf https://astral.sh/uv/install.sh | sh', 'ENV UV_LOCAL_BIN=$HOME/.cargo/bin', - 'ENV PATH=${PATH}:${USR_LOCAL_BIN}:${LOCAL_BIN}:${PNPM_HOME}:${UV_LOCAL_BIN}', + '# Only the new entry is appended — the rest are already on PATH from USER_ENV.', + 'ENV PATH=${PATH}:${UV_LOCAL_BIN}', '# Install Python 3.12 with uv', 'RUN uv python install 3.12', ] diff --git a/packages/core/src/domain/install-commands.ts b/packages/core/src/domain/install-commands.ts index 98e6557..7a8eae1 100644 --- a/packages/core/src/domain/install-commands.ts +++ b/packages/core/src/domain/install-commands.ts @@ -45,11 +45,17 @@ USER root RUN curl -fsSL -o /tmp/node-install.sh https://raw.githubusercontent.com/devcontainers/features/main/src/node/install.sh && HOME=/root bash /tmp/node-install.sh && rm -f /tmp/node-install.sh RUN mkdir -p \${HOME}/.npm && chown -R vscode:vscode \${HOME}/.npm USER vscode -ENV PNPM_HOME=\${HOME}/.local/share/pnpm -# pnpm drops global bins straight into \$PNPM_HOME, but its own preflight check -# refuses to run a global install unless \$PNPM_HOME/bin is on PATH too, so both -# have to be listed or 'pnpm install -g' aborts with "not in PATH". -ENV PATH=\${PATH}:\${PNPM_HOME}:\${PNPM_HOME}/bin +# Upstream installs node under nvm at /usr/local/share/nvm and only exposes it +# through a snippet appended to the shell rc files, so node/npm/npx/corepack/pnpm +# are invisible to any non-interactive command ('dcw exec -- node -v', an +# npm bin's '#!/usr/bin/env node' shebang, sshd exec channels). The script sets +# NVM_SYMLINK_CURRENT=true, so \$NVM_DIR/current is a stable symlink to the +# default version's install dir — bake its bin dir into the image PATH. +# \$PNPM_HOME is already exported and on PATH from the base image; pnpm links its +# global bins straight into \$PNPM_HOME, but its own preflight check refuses to +# run a global install unless \$PNPM_HOME/bin is on PATH too, so add that here. +ENV NVM_DIR=/usr/local/share/nvm +ENV PATH=\${NVM_DIR}/current/bin:\${PATH}:\${PNPM_HOME}/bin `, // Frameworks foundry: ` @@ -158,7 +164,8 @@ ENV PATH="/home/vscode/.cyfrin/bin:$PATH" RUN /bin/zsh -c "source ~/.zshrc && (~/.cyfrin/bin/cyfrinup || cyfrinup)"`, // AI coding agents (npm packages; installed globally with npm so the bin lands - // next to node on the nvm PATH — reachable from an interactive zsh at runtime). + // in $NVM_DIR/current/bin next to node — on the image's ENV PATH, so they are + // reachable from a non-interactive `dcw exec` too, not just a login shell). claude: ` # Install Anthropic Claude Code CLI RUN npm install -g @anthropic-ai/claude-code diff --git a/packages/core/test/unit/__snapshots__/containerfile.test.ts.snap b/packages/core/test/unit/__snapshots__/containerfile.test.ts.snap index f9bde90..6b7a7da 100644 --- a/packages/core/test/unit/__snapshots__/containerfile.test.ts.snap +++ b/packages/core/test/unit/__snapshots__/containerfile.test.ts.snap @@ -77,7 +77,8 @@ RUN sudo apt-get update \\ # Install uv RUN curl -LsSf https://astral.sh/uv/install.sh | sh ENV UV_LOCAL_BIN=$HOME/.cargo/bin -ENV PATH=\${PATH}:\${USR_LOCAL_BIN}:\${LOCAL_BIN}:\${PNPM_HOME}:\${UV_LOCAL_BIN} +# Only the new entry is appended — the rest are already on PATH from USER_ENV. +ENV PATH=\${PATH}:\${UV_LOCAL_BIN} # Install Python 3.12 with uv RUN uv python install 3.12 diff --git a/packages/core/test/unit/install-commands.test.ts b/packages/core/test/unit/install-commands.test.ts index 99c3566..f8e275f 100644 --- a/packages/core/test/unit/install-commands.test.ts +++ b/packages/core/test/unit/install-commands.test.ts @@ -30,9 +30,25 @@ describe('install snippets', () => { expect(INSTALL_COMMANDS.node).not.toMatch(/install\.sh\s*\|\s*bash/) }) - it('node puts both $PNPM_HOME and $PNPM_HOME/bin on PATH', () => { - // pnpm links global bins into $PNPM_HOME itself, but refuses to run - // `pnpm install -g` unless $PNPM_HOME/bin is also on PATH. - expect(INSTALL_COMMANDS.node).toContain('ENV PATH=${PATH}:${PNPM_HOME}:${PNPM_HOME}/bin') + it("node bakes nvm's bin dir into the image PATH, not just a shell rc", () => { + // Upstream's install.sh puts node under $NVM_DIR (/usr/local/share/nvm) and + // only sources it from ~/.zshrc, so a non-interactive `dcw exec -- node` + // — and every `#!/usr/bin/env node` shebang in an npm/pnpm global bin — fails + // with "node: not found". It exports NVM_SYMLINK_CURRENT=true, so + // $NVM_DIR/current is a stable symlink to the default version. + expect(INSTALL_COMMANDS.node).toContain('ENV NVM_DIR=/usr/local/share/nvm') + expect(INSTALL_COMMANDS.node).toContain('${NVM_DIR}/current/bin') + expect(INSTALL_COMMANDS.node).toMatch(/^ENV PATH=\$\{NVM_DIR\}\/current\/bin:/m) + }) + + it('node puts $PNPM_HOME/bin on PATH', () => { + // pnpm links global bins into $PNPM_HOME (already exported and on PATH by the + // base image), but refuses to run `pnpm install -g` unless $PNPM_HOME/bin is + // on PATH too. + expect(INSTALL_COMMANDS.node).toContain('${PNPM_HOME}/bin') + // …and does not re-export or re-append $PNPM_HOME itself: the base image + // already did, and chained `ENV PATH=${PATH}:…` lines duplicate entries. + expect(INSTALL_COMMANDS.node).not.toMatch(/^ENV PNPM_HOME=/m) + expect(INSTALL_COMMANDS.node).not.toMatch(/\$\{PNPM_HOME\}(?!\/bin)/) }) }) From 8b277de8d3e23b2887d4af161f58cc74c062bd05 Mon Sep 17 00:00:00 2001 From: d4rm5 Date: Thu, 20 Aug 2026 16:09:14 -0300 Subject: [PATCH 5/7] chore: ignore the repo-root .pnpm-store directory A pnpm invocation from the repo root drops a 19M content-addressable store at .pnpm-store/, which showed up as untracked noise in every status check and was one stray `git add .` away from being committed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0189PbnZFz2Ga2LxY2TLyVK2 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index fc4ecaa..29d1d3a 100644 --- a/.gitignore +++ b/.gitignore @@ -109,3 +109,6 @@ node_modules # Hardhat Ignition default folder for deployments against a local node ignition/deployments/chain-31337 + +# pnpm content-addressable store (created by pnpm runs at the repo root) +.pnpm-store/ From e227ba03901c104477ed7d7cfb57756bea241682 Mon Sep 17 00:00:00 2001 From: d4rm5 Date: Thu, 20 Aug 2026 17:40:43 -0300 Subject: [PATCH 6/7] test: cover every feature, and fix the wizard dropping seeded selections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Takes the suite from 278 to 733 tests and from 70% to 100% coverage of `src/` on all four metrics, with the bars pinned at 100 in vitest.config so a newly uncovered path fails the run. Seven commands had no in-process coverage at all (build, up, ls, create, engines, schema, ssh-proxy) and several more sat well under half; the engine drivers, the SSH/attach plumbing and the whole ink wizard were largely untested. Subprocess CLI tests exercise real behaviour but are invisible to the coverage instrument, so commands are now driven in-process through a small oclif harness (test/helpers/command.ts) that stubs only parse/log/warn/error/exit/jsonEnabled. Fixes a real bug found while covering the wizard: all six multi-select steps rendered the same component type at the same position with no key, so React reused one instance and read its `initial`-seeded state exactly once. `dcw create --lang solidity --framework foundry` reached the wizard and came back empty — only the first category survived — and the cursor row leaked between steps. Two regression tests lock the fix in. Reaching 100% branches meant deleting arms no input could take rather than hiding them behind ignore pragmas: `split(re)[0] ?? ''` fallbacks that only existed for noUncheckedIndexedAccess, `m.spec.engine ?? null` where zod already defaults it, a `default:` on an index-bounded switch, and two dead arms in attach (the "could not launch" line is unreachable because the explicit-editor path throws first). All behaviour-identical. `tmpfsSizeBytes` is now exported and tested directly, and Banner owns the "no title yet" case so App needs no unreachable fallback. Also adds `pnpm test:coverage`, and `pnpm typecheck` with a new tsconfig.test.json — the tests were never type-checked before, which had let two type errors sit in existing test files. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013LFtDpUHewj56KruJAWbFz --- package.json | 2 + packages/core/package.json | 3 + packages/core/src/commands/attach.ts | 16 +- packages/core/src/commands/ls.ts | 14 +- packages/core/src/containerfile/guard.ts | 4 +- .../core/src/engine/drivers/cli-driver.ts | 4 +- packages/core/src/engine/host.ts | 2 +- packages/core/src/hardening/effects.ts | 14 +- packages/core/src/wizard/App.tsx | 24 +- .../core/src/wizard/components/Banner.tsx | 5 +- packages/core/test/engine/fake-driver.ts | 18 +- packages/core/test/helpers/command.ts | 87 ++++ packages/core/test/helpers/fixtures.ts | 46 ++ .../test/unit/apple-container-driver.test.ts | 99 ++++ packages/core/test/unit/attach-ssh.test.ts | 2 +- packages/core/test/unit/base-command.test.ts | 148 ++++++ packages/core/test/unit/cli-context.test.ts | 259 ++++++++++ packages/core/test/unit/cli-driver.test.ts | 423 +++++++++++++++ packages/core/test/unit/cmd-agent.test.ts | 265 ++++++++++ packages/core/test/unit/cmd-attach.test.ts | 480 ++++++++++++++++++ packages/core/test/unit/cmd-build.test.ts | 143 ++++++ packages/core/test/unit/cmd-create.test.ts | 319 ++++++++++++ packages/core/test/unit/cmd-engines.test.ts | 121 +++++ packages/core/test/unit/cmd-ls.test.ts | 159 ++++++ packages/core/test/unit/cmd-rm-stop.test.ts | 254 +++++++++ packages/core/test/unit/cmd-schema.test.ts | 23 + packages/core/test/unit/cmd-skill.test.ts | 22 + packages/core/test/unit/cmd-streaming.test.ts | 202 ++++++++ packages/core/test/unit/cmd-up.test.ts | 211 ++++++++ packages/core/test/unit/coverage-gaps.test.ts | 258 ++++++++++ packages/core/test/unit/dns-probe.test.ts | 103 ++++ packages/core/test/unit/edge-cases.test.ts | 283 +++++++++++ packages/core/test/unit/engine-exec.test.ts | 66 +++ .../core/test/unit/engine-host-detect.test.ts | 87 ++++ .../core/test/unit/engine-registry.test.ts | 27 + packages/core/test/unit/free-port.test.ts | 49 ++ .../core/test/unit/generate-guard.test.ts | 23 + packages/core/test/unit/package-entry.test.ts | 28 + .../core/test/unit/resolver-defaults.test.ts | 56 ++ packages/core/test/unit/ssh-core.test.ts | 164 ++++++ packages/core/test/unit/ssh-provision.test.ts | 191 +++++++ .../core/test/unit/store-nonzod-error.test.ts | 59 +++ packages/core/test/unit/which-keys.test.ts | 103 ++++ .../core/test/wizard/app-navigation.test.tsx | 278 ++++++++++ packages/core/test/wizard/components.test.tsx | 246 +++++++++ packages/core/test/wizard/keys.ts | 31 ++ packages/core/test/wizard/render-hook.ts | 24 + packages/core/test/wizard/run.test.tsx | 115 +++++ .../core/test/wizard/use-step-nav.test.ts | 39 ++ packages/core/tsconfig.test.json | 12 + packages/core/vitest.config.ts | 17 + pnpm-lock.yaml | 348 +++++++++++++ 52 files changed, 5939 insertions(+), 37 deletions(-) create mode 100644 packages/core/test/helpers/command.ts create mode 100644 packages/core/test/helpers/fixtures.ts create mode 100644 packages/core/test/unit/apple-container-driver.test.ts create mode 100644 packages/core/test/unit/base-command.test.ts create mode 100644 packages/core/test/unit/cli-context.test.ts create mode 100644 packages/core/test/unit/cli-driver.test.ts create mode 100644 packages/core/test/unit/cmd-agent.test.ts create mode 100644 packages/core/test/unit/cmd-attach.test.ts create mode 100644 packages/core/test/unit/cmd-build.test.ts create mode 100644 packages/core/test/unit/cmd-create.test.ts create mode 100644 packages/core/test/unit/cmd-engines.test.ts create mode 100644 packages/core/test/unit/cmd-ls.test.ts create mode 100644 packages/core/test/unit/cmd-rm-stop.test.ts create mode 100644 packages/core/test/unit/cmd-schema.test.ts create mode 100644 packages/core/test/unit/cmd-skill.test.ts create mode 100644 packages/core/test/unit/cmd-streaming.test.ts create mode 100644 packages/core/test/unit/cmd-up.test.ts create mode 100644 packages/core/test/unit/coverage-gaps.test.ts create mode 100644 packages/core/test/unit/dns-probe.test.ts create mode 100644 packages/core/test/unit/edge-cases.test.ts create mode 100644 packages/core/test/unit/engine-exec.test.ts create mode 100644 packages/core/test/unit/engine-host-detect.test.ts create mode 100644 packages/core/test/unit/engine-registry.test.ts create mode 100644 packages/core/test/unit/free-port.test.ts create mode 100644 packages/core/test/unit/generate-guard.test.ts create mode 100644 packages/core/test/unit/package-entry.test.ts create mode 100644 packages/core/test/unit/resolver-defaults.test.ts create mode 100644 packages/core/test/unit/ssh-core.test.ts create mode 100644 packages/core/test/unit/ssh-provision.test.ts create mode 100644 packages/core/test/unit/store-nonzod-error.test.ts create mode 100644 packages/core/test/unit/which-keys.test.ts create mode 100644 packages/core/test/wizard/app-navigation.test.tsx create mode 100644 packages/core/test/wizard/components.test.tsx create mode 100644 packages/core/test/wizard/keys.ts create mode 100644 packages/core/test/wizard/render-hook.ts create mode 100644 packages/core/test/wizard/run.test.tsx create mode 100644 packages/core/test/wizard/use-step-nav.test.ts create mode 100644 packages/core/tsconfig.test.json diff --git a/package.json b/package.json index 880c7ea..7c7e503 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "test": "pnpm --filter @theredguild/devcontainer-wizard test", "test:watch": "pnpm --filter @theredguild/devcontainer-wizard test:watch", "test:e2e": "pnpm --filter @theredguild/devcontainer-wizard test:e2e", + "test:coverage": "pnpm --filter @theredguild/devcontainer-wizard test:coverage", + "typecheck": "pnpm --filter @theredguild/devcontainer-wizard typecheck", "clean": "pnpm --filter @theredguild/devcontainer-wizard clean" }, "dependencies": { diff --git a/packages/core/package.json b/packages/core/package.json index 9d05546..122f4da 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -44,6 +44,8 @@ "dev": "node --import tsx bin/dev.js", "test": "vitest run", "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "typecheck": "tsc -p tsconfig.test.json", "test:e2e": "pnpm build && DCW_E2E=1 vitest run test/e2e", "clean": "rm -rf dist", "prepack": "pnpm run build && oclif manifest", @@ -59,6 +61,7 @@ "devDependencies": { "@types/node": "^18.19.0", "@types/react": "^18.3.12", + "@vitest/coverage-v8": "^2.1.9", "ink-testing-library": "^4.0.0", "oclif": "^4.5.0", "tsx": "^4.19.2", diff --git a/packages/core/src/commands/attach.ts b/packages/core/src/commands/attach.ts index 20dee7e..71ef1e6 100644 --- a/packages/core/src/commands/attach.ts +++ b/packages/core/src/commands/attach.ts @@ -194,20 +194,18 @@ export default class Attach extends BaseCommand { } // Prefer the translation actually applied when we started the container; else - // the state recorded for the container we reused. - const hardening: HardeningReport = startedHardening ?? - reusedHardening ?? { - appliedFlags: manifest.container?.appliedFlags ?? [], - warnings: [], - dropped: manifest.container?.droppedHardening ?? [], - unenforced: manifest.container?.unenforcedHardening ?? [], - } + // the state recorded for the container we reused. Neither is set only when a + // container is reported running but the manifest has no record of one — there + // is nothing to report about it, so the report is empty rather than invented. + const EMPTY_REPORT: HardeningReport = { appliedFlags: [], warnings: [], dropped: [], unenforced: [] } + const hardening: HardeningReport = startedHardening ?? reusedHardening ?? EMPTY_REPORT const sshCmd = `ssh ${alias}` if (!this.jsonEnabled()) { this.log(`Ready: ${alias} (${mode === 'port' ? `localhost:${port}` : 'exec proxy'}) on ${driver.displayName}.`) + // An explicitly requested editor that fails to launch has already thrown + // above, so there is no "could not launch" case left to report here. if (launched && editor) this.log(`Launched ${editorDisplayName(editor)} → ${flags.folder}.`) - else if (editor && flags.editor) this.log(`Could not launch ${editorDisplayName(editor)}.`) this.log(`\nConnect manually: ${sshCmd}`) this.log(`VS Code / Cursor: code --remote ssh-remote+${alias} ${flags.folder}`) this.log(`Zed: zed ssh://${alias}${flags.folder}`) diff --git a/packages/core/src/commands/ls.ts b/packages/core/src/commands/ls.ts index a303a9c..4f0d0ee 100644 --- a/packages/core/src/commands/ls.ts +++ b/packages/core/src/commands/ls.ts @@ -25,17 +25,19 @@ export default class Ls extends BaseCommand { // Reconcile each environment against ITS OWN engine. Probing a single // auto-detected engine reported every env created on a different one as // 'absent' even while its container was running. - const byEngine = new Map() - const unreachable = new Set() + // `spec.engine` carries a schema default of 'auto', so every manifest names an + // engine target even before one has been resolved — no null key is possible. + const byEngine = new Map() + const unreachable = new Set() const targets = flags.engine - ? new Set([flags.engine]) - : new Set(manifests.map((m) => m.engine ?? m.spec.engine ?? null)) + ? new Set([flags.engine]) + : new Set(manifests.map((m) => m.engine ?? m.spec.engine)) for (const engine of targets) { try { const { driver } = await resolveEngineFor({ requested: flags.engine, - manifestEngine: engine ?? undefined, + manifestEngine: engine, }) byEngine.set(engine, await driver.ps({ all: true })) } catch { @@ -46,7 +48,7 @@ export default class Ls extends BaseCommand { } const environments: EnvRow[] = manifests.map((m) => { - const key: string | null = flags.engine ?? m.engine ?? m.spec.engine ?? null + const key: string = flags.engine ?? m.engine ?? m.spec.engine const live = (byEngine.get(key) ?? []).find((c) => c.name === containerName(m.name)) const status = live ? /up|running/i.test(live.status) diff --git a/packages/core/src/containerfile/guard.ts b/packages/core/src/containerfile/guard.ts index 983c8a2..320c0bc 100644 --- a/packages/core/src/containerfile/guard.ts +++ b/packages/core/src/containerfile/guard.ts @@ -20,7 +20,9 @@ const DIRECTIVES = ['RUN', 'ENV', 'WORKDIR', 'USER', 'COPY', 'ADD', 'ARG', 'LABE function startsInstruction(line: string): boolean { const trimmed = line.trim() if (trimmed === '' || trimmed.startsWith('#')) return false - const first = trimmed.split(/\s+/)[0] ?? '' + // `split(re, 1).join('')` yields the first token without an indexed access, so + // there is no unreachable `?? ''` arm to satisfy noUncheckedIndexedAccess with. + const first = trimmed.split(/\s+/, 1).join('') return DIRECTIVES.includes(first) } diff --git a/packages/core/src/engine/drivers/cli-driver.ts b/packages/core/src/engine/drivers/cli-driver.ts index 7e3b94b..221d640 100644 --- a/packages/core/src/engine/drivers/cli-driver.ts +++ b/packages/core/src/engine/drivers/cli-driver.ts @@ -121,7 +121,9 @@ export abstract class CliDriver implements EngineDriver { const res = await capture(this.bin, args) if (res.code !== 0) this.fail('run', res.stderr) - return { containerId: res.stdout.trim().split('\n').pop() ?? '' } + // `slice(-1).join('')` is the last line (or '' for empty output) with no + // `pop() ?? ''` arm that no input can reach. + return { containerId: res.stdout.trim().split('\n').slice(-1).join('') } } protected execArgv(spec: ExecSpec): string[] { diff --git a/packages/core/src/engine/host.ts b/packages/core/src/engine/host.ts index b681c69..b4f8d24 100644 --- a/packages/core/src/engine/host.ts +++ b/packages/core/src/engine/host.ts @@ -33,7 +33,7 @@ export async function detectHost(): Promise { if (hostOS === 'macos') { const res = await capture('sw_vers', ['-productVersion']) if (!res.spawnError && res.code === 0) { - const major = Number.parseInt(res.stdout.trim().split('.')[0] ?? '', 10) + const major = Number.parseInt(res.stdout.trim().split('.', 1).join(''), 10) if (Number.isFinite(major)) info.macosMajor = major } } diff --git a/packages/core/src/hardening/effects.ts b/packages/core/src/hardening/effects.ts index 8b1b650..ed13f14 100644 --- a/packages/core/src/hardening/effects.ts +++ b/packages/core/src/hardening/effects.ts @@ -63,12 +63,18 @@ const READONLY_TMPFS: Array<{ target: string; opts: string }> = [ ] /** Parse a tmpfs `size=` option into bytes for comparison (Infinity if absent). */ -function tmpfsSizeBytes(opts: string): number { - const m = /size=(\d+)([kmg]?)/i.exec(opts) +export function tmpfsSizeBytes(opts: string): number { + // Match the whole `size=` token rather than capture groups: index 0 of + // a RegExpExecArray is typed `string`, so splitting the token by hand avoids an + // optional-group fallback that no input could ever take. + const m = /size=\d+[kmg]?/i.exec(opts) if (!m) return Number.POSITIVE_INFINITY - const unit = (m[2] ?? '').toLowerCase() + const token = m[0].slice('size='.length).toLowerCase() + const suffixed = /[kmg]$/.test(token) + const unit = suffixed ? token.slice(-1) : '' + const digits = suffixed ? token.slice(0, -1) : token const mult = unit === 'g' ? 1024 ** 3 : unit === 'm' ? 1024 ** 2 : unit === 'k' ? 1024 : 1 - return Number(m[1]) * mult + return Number(digits) * mult } /** diff --git a/packages/core/src/wizard/App.tsx b/packages/core/src/wizard/App.tsx index 0f7a3f9..7a5e394 100644 --- a/packages/core/src/wizard/App.tsx +++ b/packages/core/src/wizard/App.tsx @@ -133,18 +133,24 @@ export function App({ initial, engines, onComplete, onCancel }: AppProps) { onBack={back} /> ) + // Every multi-select step renders the same component type at the same + // position, so without a distinct key React reuses one instance across all + // six: its `initial`-seeded state is read once and never again. That + // silently dropped every flag-seeded selection past the first category + // (`dcw create --lang solidity` reached the wizard and came back empty) and + // leaked the cursor row from one step into the next. case 2: - return { patch({ coreLanguages: v }); next() }} onBack={back} /> + return { patch({ coreLanguages: v }); next() }} onBack={back} /> case 3: - return { patch({ languages: v }); next() }} onBack={back} /> + return { patch({ languages: v }); next() }} onBack={back} /> case 4: - return { patch({ frameworks: v }); next() }} onBack={back} /> + return { patch({ frameworks: v }); next() }} onBack={back} /> case 5: - return { patch({ fuzzingAndTesting: v }); next() }} onBack={back} /> + return { patch({ fuzzingAndTesting: v }); next() }} onBack={back} /> case 6: - return { patch({ securityTooling: v }); next() }} onBack={back} /> + return { patch({ securityTooling: v }); next() }} onBack={back} /> case 7: - return { patch({ aiAgents: v }); next() }} onBack={back} /> + return { patch({ aiAgents: v }); next() }} onBack={back} /> case 8: return ( ) - default: - return null } + // useStepNav clamps `index` to the declared steps, so there is no other case; + // an out-of-range index would simply render nothing. } return ( - + {renderStep()} ) diff --git a/packages/core/src/wizard/components/Banner.tsx b/packages/core/src/wizard/components/Banner.tsx index c44a0fb..2a242be 100644 --- a/packages/core/src/wizard/components/Banner.tsx +++ b/packages/core/src/wizard/components/Banner.tsx @@ -1,13 +1,14 @@ import { Box, Text } from 'ink' -export function Banner({ step, total, title }: { step: number; total: number; title: string }) { +export function Banner({ step, total, title }: { step: number; total: number; title?: string }) { return ( dcw · container environment wizard - Step {step + 1}/{total} — {title} + Step {step + 1}/{total} + {title ? ` — ${title}` : ''} ) diff --git a/packages/core/test/engine/fake-driver.ts b/packages/core/test/engine/fake-driver.ts index 5132d5f..a3badf1 100644 --- a/packages/core/test/engine/fake-driver.ts +++ b/packages/core/test/engine/fake-driver.ts @@ -8,6 +8,7 @@ import type { EngineDriver, EngineName, ExecSpec, + LogsOptions, PsFilter, RunSpec, } from '../../src/engine/types.js' @@ -19,6 +20,10 @@ export interface FakeDriverOptions { capabilities?: EngineCapabilities /** Result the report probe (`runOnce`) returns; defaults to a clean read. */ runOnceResult?: { stdout: string; code: number } + /** Exit code `exec` reports (the container's own status). Defaults to 0. */ + execResult?: number + /** Exit code `logs` reports. Defaults to 0. */ + logsResult?: number } /** In-memory EngineDriver that records the specs it receives — no real daemon. */ @@ -28,6 +33,8 @@ export class FakeDriver implements EngineDriver { readonly capabilities: EngineCapabilities private readonly detectResult: DetectResult private readonly runOnceResult: { stdout: string; code: number } + private readonly execResult: number + private readonly logsResult: number builds: BuildSpec[] = [] runs: RunSpec[] = [] @@ -41,6 +48,8 @@ export class FakeDriver implements EngineDriver { this.capabilities = opts.capabilities ?? caps() this.detectResult = opts.detect ?? { available: true, version: 'fake-1.0' } this.runOnceResult = opts.runOnceResult ?? { stdout: '', code: 0 } + this.execResult = opts.execResult ?? 0 + this.logsResult = opts.logsResult ?? 0 } async detect(): Promise { @@ -59,7 +68,7 @@ export class FakeDriver implements EngineDriver { async exec(spec: ExecSpec): Promise { this.execs.push(spec) - return 0 + return this.execResult } async execCapture(spec: ExecSpec): Promise { @@ -79,8 +88,11 @@ export class FakeDriver implements EngineDriver { return [] } - async logs(): Promise { - return 0 + logsCalls: Array<{ id: string; opts?: LogsOptions }> = [] + + async logs(id: string, opts?: LogsOptions): Promise { + this.logsCalls.push({ id, opts }) + return this.logsResult } runOnces: Array<{ image: string; cmd: string[]; flags: string[] }> = [] diff --git a/packages/core/test/helpers/command.ts b/packages/core/test/helpers/command.ts new file mode 100644 index 0000000..5b05ab3 --- /dev/null +++ b/packages/core/test/helpers/command.ts @@ -0,0 +1,87 @@ +import type { Command } from '@oclif/core' + +/** Thrown by the stubbed `this.exit()` so tests can observe the exit code. */ +export class ExitSignal extends Error { + constructor(readonly code: number) { + super(`exit ${code}`) + this.name = 'ExitSignal' + } +} + +export interface HarnessOptions { + args?: Record + flags?: Record + argv?: string[] + /** What `this.jsonEnabled()` reports. */ + json?: boolean + /** Version reported through `this.config.version`. */ + version?: string +} + +export interface Harness { + cmd: Record + logs: string[] + warns: string[] + jsonLogs: unknown[] +} + +/** + * Build a runnable oclif command without booting an oclif Config. + * + * Commands are plain classes whose `run()` only reaches the framework through + * `parse`/`log`/`warn`/`error`/`exit`/`jsonEnabled`/`config`, so stubbing those on + * an object created from the prototype exercises the real command body in-process + * (which a subprocess CLI test cannot do — its coverage is invisible). + */ +export function harness(Cmd: unknown, opts: HarnessOptions = {}): Harness { + const cmd: Record = Object.create((Cmd as typeof Command).prototype) + const logs: string[] = [] + const warns: string[] = [] + const jsonLogs: unknown[] = [] + + cmd.parse = async () => ({ args: opts.args ?? {}, flags: opts.flags ?? {}, argv: opts.argv ?? [] }) + cmd.jsonEnabled = () => opts.json ?? false + cmd.log = (message = '') => { + logs.push(String(message)) + } + cmd.logJson = (value: unknown) => { + jsonLogs.push(value) + } + cmd.warn = (message: string | Error) => { + warns.push(typeof message === 'string' ? message : message.message) + return message + } + cmd.error = (message: string | Error, options: Record = {}) => { + const err = typeof message === 'string' ? new Error(message) : message + throw Object.assign(err, options) + } + cmd.exit = (code = 0) => { + throw new ExitSignal(code) + } + cmd.config = { version: opts.version ?? '2.0.0', bin: 'dcw' } + + return { cmd, logs, warns, jsonLogs } +} + +export interface RunResult { + result: T | undefined + logs: string[] + warns: string[] + jsonLogs: unknown[] + /** Set when the command called `this.exit()`; undefined when it returned normally. */ + exitCode: number | undefined +} + +/** Run a command in-process, capturing its output and exit code. */ +export async function runCommand(Cmd: unknown, opts: HarnessOptions = {}): Promise> { + const h = harness(Cmd, opts) + try { + const result = (await h.cmd.run()) as T + return { result, logs: h.logs, warns: h.warns, jsonLogs: h.jsonLogs, exitCode: undefined } + } catch (err) { + if (err instanceof ExitSignal) { + return { result: undefined, logs: h.logs, warns: h.warns, jsonLogs: h.jsonLogs, exitCode: err.code } + } + throw err + } +} diff --git a/packages/core/test/helpers/fixtures.ts b/packages/core/test/helpers/fixtures.ts new file mode 100644 index 0000000..482dd2f --- /dev/null +++ b/packages/core/test/helpers/fixtures.ts @@ -0,0 +1,46 @@ +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { SCHEMA_VERSION, type EnvManifest } from '../../src/state/manifest.js' + +const T0 = '2026-06-11T00:00:00.000Z' + +/** A minimal, schema-valid manifest; `over` is merged shallowly over the defaults. */ +export function manifest(over: Partial = {}): EnvManifest { + const name = over.name ?? 'env1' + return { + schemaVersion: SCHEMA_VERSION, + name, + createdAt: T0, + updatedAt: T0, + spec: { name, engine: 'auto', selections: {}, hardening: [], ssh: true }, + resolved: { requiredTools: [], hardeningKeys: [] }, + engine: null, + image: null, + container: null, + ...over, + } as EnvManifest +} + +/** + * Point the XDG roots at a fresh temp dir for the duration of a test, and return + * a disposer. dcw resolves every state path lazily through these, so this keeps + * manifests out of the developer's real ~/.config. + */ +export async function useTempState(prefix = 'dcw-test-'): Promise<{ dir: string; cleanup: () => Promise }> { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)) + const prevConfig = process.env.XDG_CONFIG_HOME + const prevState = process.env.XDG_STATE_HOME + process.env.XDG_CONFIG_HOME = path.join(dir, 'config') + process.env.XDG_STATE_HOME = path.join(dir, 'state') + return { + dir, + cleanup: async () => { + if (prevConfig === undefined) delete process.env.XDG_CONFIG_HOME + else process.env.XDG_CONFIG_HOME = prevConfig + if (prevState === undefined) delete process.env.XDG_STATE_HOME + else process.env.XDG_STATE_HOME = prevState + await fs.rm(dir, { recursive: true, force: true }) + }, + } +} diff --git a/packages/core/test/unit/apple-container-driver.test.ts b/packages/core/test/unit/apple-container-driver.test.ts new file mode 100644 index 0000000..4612758 --- /dev/null +++ b/packages/core/test/unit/apple-container-driver.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { CaptureResult } from '../../src/engine/exec.js' + +const captured: Array<{ bin: string; args: string[] }> = [] +let script: Array<{ match: RegExp; result: Partial }> = [] + +vi.mock('../../src/engine/exec.js', () => ({ + capture: async (bin: string, args: string[]): Promise => { + captured.push({ bin, args }) + const hit = script.find((s) => s.match.test(`${bin} ${args.join(' ')}`)) + return { code: 0, stdout: '', stderr: '', spawnError: false, ...(hit?.result ?? {}) } + }, + inherit: async () => 0, +})) + +const { AppleContainerDriver } = await import('../../src/engine/drivers/apple-container.js') + +const argv = (i: { bin: string; args: string[] }) => `${i.bin} ${i.args.join(' ')}` + +beforeEach(() => { + captured.length = 0 + script = [] +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('AppleContainerDriver.detect', () => { + it('reports the CLI version and probes the service with `container list`', async () => { + script = [{ match: /container --version/, result: { stdout: 'container CLI version 1.0.0\nbuild x\n' } }] + expect(await new AppleContainerDriver().detect()).toEqual({ + available: true, + version: 'container CLI version 1.0.0', + }) + expect(captured.map(argv)).toEqual(['container --version', 'container list']) + }) + + it('reports the CLI as missing when it cannot be spawned', async () => { + script = [{ match: /container --version/, result: { spawnError: true, code: 127 } }] + expect(await new AppleContainerDriver().detect()).toEqual({ + available: false, + reason: '`container` CLI not found on PATH.', + }) + }) + + it("surfaces the service's own error when it is not running", async () => { + script = [{ match: /container list/, result: { code: 1, stderr: 'XPC connection error\n' } }] + expect(await new AppleContainerDriver().detect()).toMatchObject({ + available: false, + reason: 'XPC connection error', + }) + }) + + it('falls back to a generic reason when the failing probe said nothing', async () => { + script = [{ match: /container list/, result: { code: 1 } }] + expect(await new AppleContainerDriver().detect()).toMatchObject({ + reason: 'Apple Containers service is not running.', + }) + }) +}) + +describe('AppleContainerDriver.ps', () => { + const rows = JSON.stringify([ + { id: 'dcw-a', status: { state: 'running' }, configuration: { labels: { 'dcw.env': 'a' }, image: { reference: 'img' } } }, + { id: 'buildkit', status: { state: 'stopped' }, configuration: { labels: {}, image: { reference: 'b' } } }, + ]) + + it('uses Apple\'s `list --format json`, which has no --filter flag', async () => { + script = [{ match: /container list/, result: { stdout: rows } }] + const res = await new AppleContainerDriver().ps() + expect(argv(captured[0]!)).toBe('container list --format json --all') + expect(res.map((c) => c.id)).toEqual(['dcw-a', 'buildkit']) + }) + + it('omits --all when stopped containers are not wanted', async () => { + await new AppleContainerDriver().ps({ all: false }) + expect(argv(captured[0]!)).toBe('container list --format json') + }) + + it('applies the label filter client-side', async () => { + script = [{ match: /container list/, result: { stdout: rows } }] + const res = await new AppleContainerDriver().ps({ label: 'dcw.env=a' }) + expect(res.map((c) => c.id)).toEqual(['dcw-a']) + }) + + it('returns an empty list when the service is unreachable', async () => { + script = [{ match: /container list/, result: { code: 1 } }] + expect(await new AppleContainerDriver().ps()).toEqual([]) + }) +}) + +describe('AppleContainerDriver verbs', () => { + it('renames rm to `delete` and ps to `list`', async () => { + const driver = new AppleContainerDriver() + await driver.rm('dcw-a', { force: true }) + expect(argv(captured[0]!)).toBe('container delete -f dcw-a') + }) +}) diff --git a/packages/core/test/unit/attach-ssh.test.ts b/packages/core/test/unit/attach-ssh.test.ts index 99e752b..7386016 100644 --- a/packages/core/test/unit/attach-ssh.test.ts +++ b/packages/core/test/unit/attach-ssh.test.ts @@ -23,7 +23,7 @@ function builtManifest(s: EnvSpec): EnvManifest { spec: s, resolved: { requiredTools: [], hardeningKeys: s.hardening }, engine: 'docker', - image: { tag: `dcw/${s.name}:latest`, id: 'sha256:x', containerfileHash: 'h', builtAt: NOW }, + image: { tag: `dcw/${s.name}:latest`, imageId: 'sha256:x', containerfileHash: 'h', builtAt: NOW }, container: null, } } diff --git a/packages/core/test/unit/base-command.test.ts b/packages/core/test/unit/base-command.test.ts new file mode 100644 index 0000000..9d3cd36 --- /dev/null +++ b/packages/core/test/unit/base-command.test.ts @@ -0,0 +1,148 @@ +import { Errors } from '@oclif/core' +import { describe, expect, it } from 'vitest' +import { BaseCommand } from '../../src/base-command.js' +import { + CancelledError, + DcwError, + EngineDnsError, + EngineUnavailableError, + EngineUnsupportedError, + ExitCode, + NoEngineError, + NotFoundError, + StrictHardeningError, + ValidationError, +} from '../../src/errors.js' +import { ExitSignal, harness } from '../helpers/command.js' + +class Probe extends BaseCommand { + async run(): Promise {} +} + +/** Drive the protected `catch` hook the way oclif's runner does. */ +async function catchWith(err: Error, json: boolean) { + const h = harness(Probe, { json }) + // oclif's own fallback handler sets process.exitCode; keep it out of the runner. + const prevExitCode = process.exitCode + try { + await (h.cmd as unknown as { catch(e: Error): Promise }).catch(err) + return { ...h, exitCode: undefined as number | undefined, thrown: undefined as unknown } + } catch (thrown) { + if (thrown instanceof ExitSignal) return { ...h, exitCode: thrown.code, thrown: undefined } + return { ...h, exitCode: undefined, thrown } + } finally { + process.exitCode = prevExitCode + } +} + +describe('exit codes', () => { + it('assigns each domain error its documented exit code and machine code', () => { + const cases: Array<[DcwError, number, string]> = [ + [new DcwError('x'), ExitCode.GenericError, 'E_GENERIC'], + [new NoEngineError('x'), ExitCode.NoEngine, 'E_NO_ENGINE'], + [new EngineUnsupportedError('x'), ExitCode.EngineUnsupported, 'E_ENGINE_UNSUPPORTED'], + [new EngineUnavailableError('x'), ExitCode.EngineUnavailable, 'E_ENGINE_UNAVAILABLE'], + [new EngineDnsError('x'), ExitCode.EngineDns, 'E_ENGINE_DNS'], + [new StrictHardeningError('x'), ExitCode.StrictHardening, 'E_STRICT_HARDENING'], + [new NotFoundError('x'), ExitCode.NotFound, 'E_NOT_FOUND'], + [new ValidationError('x'), ExitCode.ValidationError, 'E_VALIDATION'], + [new CancelledError(), ExitCode.Cancelled, 'E_CANCELLED'], + ] + for (const [err, exitCode, code] of cases) { + expect([err.exitCode, err.code]).toEqual([exitCode, code]) + expect(err).toBeInstanceOf(DcwError) + } + }) + + it('gives each error class its own name and a default cancel message', () => { + expect(new NoEngineError('x').name).toBe('NoEngineError') + expect(new EngineDnsError('x').name).toBe('EngineDnsError') + expect(new CancelledError().message).toBe('Cancelled.') + expect(new CancelledError('user quit').message).toBe('user quit') + }) +}) + +describe('BaseCommand.catch', () => { + it('emits a {error:{code,message}} envelope and the domain exit code under --json', async () => { + const res = await catchWith(new NotFoundError("Environment 'x' not found."), true) + expect(res.jsonLogs).toEqual([{ error: { code: 'E_NOT_FOUND', message: "Environment 'x' not found." } }]) + expect(res.exitCode).toBe(ExitCode.NotFound) + }) + + it('raises human text carrying the same exit and machine code without --json', async () => { + const res = await catchWith(new ValidationError('bad name'), false) + expect(res.jsonLogs).toEqual([]) + expect(res.thrown).toMatchObject({ message: 'bad name', exit: ExitCode.ValidationError, code: 'E_VALIDATION' }) + }) + + it('honors the --json contract for oclif usage errors instead of dumping its internals', async () => { + const err = new Errors.CLIError('Nonexistent flag: --nope') + err.oclif = { exit: ExitCode.UsageError } + + const res = await catchWith(err, true) + + // Left to oclif this serializes the entire CLIError — config, home dir, plugin + // list — to stdout with no code/message. + expect(res.jsonLogs).toEqual([{ error: { code: 'E_USAGE', message: 'Nonexistent flag: --nope' } }]) + expect(res.exitCode).toBe(ExitCode.UsageError) + }) + + it('labels a non-usage CLI error E_CLI and keeps its own exit code', async () => { + const err = new Errors.CLIError('something CLI-ish') + err.oclif = { exit: 4 } + const res = await catchWith(err, true) + expect(res.jsonLogs).toEqual([{ error: { code: 'E_CLI', message: 'something CLI-ish' } }]) + expect(res.exitCode).toBe(4) + }) + + it('defaults to the usage exit code when oclif attached none', async () => { + const err = new Errors.CLIError('no exit attached') + err.oclif = {} as never + const res = await catchWith(err, true) + expect(res.jsonLogs).toEqual([{ error: { code: 'E_USAGE', message: 'no exit attached' } }]) + expect(res.exitCode).toBe(ExitCode.UsageError) + }) + + it('lets a deliberate this.exit() pass through untouched', async () => { + // ExitError is not an error: turning it into an envelope would corrupt the + // exit code of every streaming command. + const res = await catchWith(new Errors.ExitError(0), true) + // It is handed to oclif untouched rather than rewritten into a dcw envelope. + expect(res.jsonLogs).toEqual([{ error: expect.any(Errors.ExitError) }]) + expect(res.exitCode).toBeUndefined() + }) + + it('wraps an untyped failure in the same envelope, never a raw Node error object', async () => { + const err = Object.assign(new Error('ENOENT: no such file'), { code: 'ENOENT' }) + const res = await catchWith(err, true) + expect(res.jsonLogs).toEqual([{ error: { code: 'E_INTERNAL', message: 'ENOENT: no such file (ENOENT)' } }]) + expect(res.exitCode).toBe(ExitCode.GenericError) + }) + + it('omits the parenthetical when the failure carries no errno code', async () => { + const res = await catchWith(new Error('plain bug'), true) + expect(res.jsonLogs).toEqual([{ error: { code: 'E_INTERNAL', message: 'plain bug' } }]) + }) + + it('leaves non-JSON handling of untyped errors to oclif', async () => { + const res = await catchWith(new Error('plain bug'), false) + expect(res.jsonLogs).toEqual([]) + expect(res.thrown).toBeInstanceOf(Error) + }) +}) + +describe('base flags', () => { + it('exposes the global AI-native flags on every command', () => { + expect(Object.keys(BaseCommand.baseFlags)).toEqual(['yes', 'no-input', 'engine', 'strict']) + }) + + it('restricts --engine to the known engines plus auto, and reads DCW_ENGINE', () => { + const engine = BaseCommand.baseFlags.engine as unknown as { options: string[]; env: string } + expect(engine.options).toEqual(['auto', 'docker', 'podman', 'orbstack', 'apple-container', 'lima']) + expect(engine.env).toBe('DCW_ENGINE') + }) + + it('enables the JSON flag by default', () => { + expect(BaseCommand.enableJsonFlag).toBe(true) + }) +}) diff --git a/packages/core/test/unit/cli-context.test.ts b/packages/core/test/unit/cli-context.test.ts new file mode 100644 index 0000000..50f1552 --- /dev/null +++ b/packages/core/test/unit/cli-context.test.ts @@ -0,0 +1,259 @@ +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { FakeDriver } from '../engine/fake-driver.js' +import { manifest, useTempState } from '../helpers/fixtures.js' + +let driver: FakeDriver +const resolveEngine = vi.fn(async (_opts: unknown) => ({ driver, detect: { available: true }, platform: { supported: true } })) +const detectHost = vi.fn(async () => ({ os: 'linux' as const, arch: 'x64' as const })) + +vi.mock('../../src/engine/resolver.js', async (orig) => { + const actual = await orig() + return { ...actual, resolveEngine: (opts: unknown) => resolveEngine(opts) } +}) +vi.mock('../../src/engine/host.js', async (orig) => { + const actual = await orig() + return { ...actual, detectHost: () => detectHost() } +}) + +const { assertStrictContainer, execInto, nowIso, requireManifest, resolveEngineFor, resolveEnvName } = await import( + '../../src/cli/context.js' +) +const { saveManifest } = await import('../../src/state/store.js') + +let state: Awaited> +let cwd: string + +beforeEach(async () => { + state = await useTempState('dcw-ctx-') + cwd = path.join(state.dir, 'work') + await fs.mkdir(cwd, { recursive: true }) + vi.spyOn(process, 'cwd').mockReturnValue(cwd) + driver = new FakeDriver({ name: 'docker' }) + resolveEngine.mockClear() + detectHost.mockClear() +}) + +afterEach(async () => { + vi.restoreAllMocks() + await state.cleanup() +}) + +describe('resolveEnvName', () => { + it('uses an explicit positional name', async () => { + expect(await resolveEnvName('my-env')).toBe('my-env') + }) + + it('rejects a name that is not a safe slug, before it reaches the filesystem', async () => { + // Unchecked, '../…' is spliced verbatim into manifest and state paths. + await expect(resolveEnvName('../../etc/passwd')).rejects.toMatchObject({ code: 'E_VALIDATION' }) + await expect(resolveEnvName('Has Spaces')).rejects.toMatchObject({ code: 'E_VALIDATION' }) + }) + + it('falls back to the .dcw marker in the working directory', async () => { + await fs.writeFile(path.join(cwd, '.dcw'), ' pinned\n') + expect(await resolveEnvName()).toBe('pinned') + }) + + it('validates the .dcw marker too, and surfaces the error rather than falling through', async () => { + await fs.writeFile(path.join(cwd, '.dcw'), '../escape') + await saveManifest(manifest({ name: 'only' })) + await expect(resolveEnvName()).rejects.toMatchObject({ code: 'E_VALIDATION' }) + }) + + it('ignores an empty .dcw marker and continues resolving', async () => { + await fs.writeFile(path.join(cwd, '.dcw'), ' \n') + await saveManifest(manifest({ name: 'only' })) + expect(await resolveEnvName()).toBe('only') + }) + + it('resolves the sole environment when there is exactly one', async () => { + await saveManifest(manifest({ name: 'only' })) + expect(await resolveEnvName()).toBe('only') + }) + + it('reports E_NOT_FOUND with a create hint when there are no environments', async () => { + await expect(resolveEnvName()).rejects.toMatchObject({ code: 'E_NOT_FOUND', message: expect.stringContaining('dcw create') }) + }) + + it('lists the candidates when the choice is ambiguous', async () => { + await saveManifest(manifest({ name: 'alpha' })) + await saveManifest(manifest({ name: 'beta' })) + await expect(resolveEnvName()).rejects.toMatchObject({ + code: 'E_NOT_FOUND', + message: expect.stringContaining('alpha, beta'), + }) + }) +}) + +describe('requireManifest', () => { + it('returns the stored manifest', async () => { + await saveManifest(manifest({ name: 'env1' })) + expect((await requireManifest('env1')).name).toBe('env1') + }) + + it('raises E_NOT_FOUND for an environment that does not exist', async () => { + await expect(requireManifest('nope')).rejects.toMatchObject({ code: 'E_NOT_FOUND' }) + }) +}) + +describe('resolveEngineFor', () => { + it('prefers the --engine flag over the manifest preference', async () => { + await resolveEngineFor({ requested: 'podman', manifestEngine: 'docker' }) + expect(resolveEngine).toHaveBeenCalledWith({ requested: 'podman', host: { os: 'linux', arch: 'x64' } }) + }) + + it("falls back to the manifest's saved engine", async () => { + await resolveEngineFor({ requested: undefined, manifestEngine: 'lima' }) + expect(resolveEngine.mock.calls[0]?.[0]).toMatchObject({ requested: 'lima' }) + }) + + it("treats 'auto' on either side as 'no preference'", async () => { + await resolveEngineFor({ requested: 'auto', manifestEngine: 'auto' }) + expect(resolveEngine.mock.calls[0]?.[0]).toMatchObject({ requested: undefined }) + }) + + it('ignores a null manifest engine', async () => { + await resolveEngineFor({ manifestEngine: null }) + expect(resolveEngine.mock.calls[0]?.[0]).toMatchObject({ requested: undefined }) + }) + + it('returns the driver alongside its name, capabilities, host and detect result', async () => { + const ctx = await resolveEngineFor({}) + expect(ctx.engineName).toBe('docker') + expect(ctx.capabilities).toBe(driver.capabilities) + expect(ctx.host).toEqual({ os: 'linux', arch: 'x64' }) + expect(ctx.detect).toEqual({ available: true }) + }) +}) + +describe('nowIso', () => { + it('formats the current instant as an ISO-8601 timestamp', () => { + expect(nowIso()).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) + }) +}) + +describe('assertStrictContainer', () => { + const running = (over: Record) => + manifest({ name: 'env1', container: { name: 'dcw-env1', ...over } as never }) + + it('does nothing when --strict is off', () => { + expect(() => assertStrictContainer(running({ droppedHardening: ['apparmor'] }), false)).not.toThrow() + expect(() => assertStrictContainer(running({ droppedHardening: ['apparmor'] }), undefined)).not.toThrow() + }) + + it('passes a container whose hardening was fully honored', () => { + expect(() => assertStrictContainer(running({ droppedHardening: [], unenforcedHardening: [] }), true)).not.toThrow() + expect(() => assertStrictContainer(manifest({ name: 'env1' }), true)).not.toThrow() + }) + + it('refuses to enter a container with dropped hardening', () => { + expect(() => assertStrictContainer(running({ droppedHardening: ['network-none'] }), true)).toThrow( + /network-none/, + ) + }) + + it('refuses a container whose hardening was emitted but is unenforced', () => { + // A flag that is present but inert is exactly the silent failure --strict exists for. + expect(() => assertStrictContainer(running({ unenforcedHardening: ['apparmor'] }), true)).toThrow(/apparmor/) + }) +}) + +describe('execInto', () => { + const withContainer = (over: Record = {}) => + manifest({ + name: 'env1', + engine: 'docker', + container: { id: 'cid-1', name: 'dcw-env1', status: 'running', ...over } as never, + }) + + it('execs the given command into the recorded container id', async () => { + await saveManifest(withContainer()) + const code = await execInto({ name: 'env1', cmd: ['forge', '--version'] }) + expect(code).toBe(0) + expect(driver.execs[0]).toMatchObject({ container: 'cid-1', cmd: ['forge', '--version'] }) + }) + + it('falls back to the container name when no id was recorded', async () => { + await saveManifest(withContainer({ id: undefined })) + await execInto({ name: 'env1', cmd: ['ls'] }) + expect(driver.execs[0]?.container).toBe('dcw-env1') + }) + + it('defaults to an interactive zsh when no command is given', async () => { + await saveManifest(withContainer()) + await execInto({ name: 'env1', cmd: [] }) + expect(driver.execs[0]?.cmd).toEqual(['zsh']) + }) + + it('forwards the requested environment variables', async () => { + await saveManifest(withContainer()) + await execInto({ name: 'env1', cmd: ['env'], env: { GITHUB_TOKEN: 'ghp_x' } }) + expect(driver.execs[0]?.env).toEqual({ GITHUB_TOKEN: 'ghp_x' }) + }) + + it('allocates a TTY only when both stdin and stdout are terminals', async () => { + await saveManifest(withContainer()) + const stdin = process.stdin as unknown as { isTTY: boolean | undefined } + const stdout = process.stdout as unknown as { isTTY: boolean | undefined } + const prev = [stdin.isTTY, stdout.isTTY] as const + + try { + stdin.isTTY = true + stdout.isTTY = true + await execInto({ name: 'env1', cmd: ['ls'] }) + expect(driver.execs.at(-1)).toMatchObject({ interactive: true, tty: true }) + + // Piped/CI/agent invocation: no TTY, so `dcw exec env -- cmd` still works. + stdout.isTTY = false + await execInto({ name: 'env1', cmd: ['ls'] }) + expect(driver.execs.at(-1)).toMatchObject({ interactive: false, tty: false }) + } finally { + stdin.isTTY = prev[0] + stdout.isTTY = prev[1] + } + }) + + it('raises E_NOT_FOUND with an `up` hint when the environment has no container', async () => { + await saveManifest(manifest({ name: 'env1' })) + await expect(execInto({ name: 'env1', cmd: ['ls'] })).rejects.toMatchObject({ + code: 'E_NOT_FOUND', + message: expect.stringContaining('dcw up env1'), + }) + }) + + it('refuses under --strict when the running container lost hardening', async () => { + await saveManifest(withContainer({ droppedHardening: ['network-none'] })) + await expect(execInto({ name: 'env1', cmd: ['ls'], strict: true })).rejects.toMatchObject({ + code: 'E_STRICT_HARDENING', + }) + expect(driver.execs).toEqual([]) + }) + + it("falls back to the spec's engine when the manifest has no resolved engine", async () => { + await saveManifest( + manifest({ + name: 'env1', + engine: null, + spec: { name: 'env1', engine: 'lima', selections: {}, hardening: [], ssh: true }, + container: { id: 'cid-1', name: 'dcw-env1' } as never, + }), + ) + await execInto({ name: 'env1', cmd: ['ls'] }) + expect(resolveEngine.mock.calls[0]?.[0]).toMatchObject({ requested: 'lima' }) + }) + + it("resolves the engine from the manifest, preferring its resolved engine over the spec's", async () => { + await saveManifest( + manifest({ + name: 'env1', + engine: 'podman', + spec: { name: 'env1', engine: 'lima', selections: {}, hardening: [], ssh: true }, + container: { id: 'cid-1', name: 'dcw-env1' } as never, + }), + ) + await execInto({ name: 'env1', cmd: ['ls'] }) + expect(resolveEngine.mock.calls[0]?.[0]).toMatchObject({ requested: 'podman' }) + }) +}) diff --git a/packages/core/test/unit/cli-driver.test.ts b/packages/core/test/unit/cli-driver.test.ts new file mode 100644 index 0000000..47e4dcb --- /dev/null +++ b/packages/core/test/unit/cli-driver.test.ts @@ -0,0 +1,423 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { CaptureResult } from '../../src/engine/exec.js' + +interface Invocation { + bin: string + args: string[] + opts?: Record +} + +const captured: Invocation[] = [] +const inherited: Invocation[] = [] +/** Scripted `capture` replies matched against the joined argv, in declaration order. */ +let script: Array<{ match: RegExp; result: Partial }> = [] +let inheritCode = 0 + +vi.mock('../../src/engine/exec.js', () => ({ + capture: async (bin: string, args: string[], opts?: Record): Promise => { + captured.push({ bin, args, opts }) + const hit = script.find((s) => s.match.test(`${bin} ${args.join(' ')}`)) + return { code: 0, stdout: '', stderr: '', spawnError: false, ...(hit?.result ?? {}) } + }, + inherit: async (bin: string, args: string[], opts?: Record): Promise => { + inherited.push({ bin, args, opts }) + return inheritCode + }, +})) + +const { DockerDriver } = await import('../../src/engine/drivers/docker.js') +const { PodmanDriver } = await import('../../src/engine/drivers/podman.js') +const { LimaDriver } = await import('../../src/engine/drivers/lima.js') +const { OrbstackDriver } = await import('../../src/engine/drivers/orbstack.js') +const { parsePsJson } = await import('../../src/engine/drivers/cli-driver.js') + +const argvOf = (i: Invocation) => `${i.bin} ${i.args.join(' ')}` + +beforeEach(() => { + captured.length = 0 + inherited.length = 0 + script = [] + inheritCode = 0 +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('CliDriver.detect', () => { + it('reports the first version line and probes daemon readiness', async () => { + script = [{ match: /docker --version/, result: { stdout: 'Docker version 27.0.3\nextra\n' } }] + expect(await new DockerDriver().detect()).toEqual({ available: true, version: 'Docker version 27.0.3' }) + expect(captured.map(argvOf)).toContain('docker info') + }) + + it('reports the binary as missing when it cannot be spawned', async () => { + script = [{ match: /podman --version/, result: { spawnError: true, code: 127 } }] + expect(await new PodmanDriver().detect()).toEqual({ available: false, reason: 'podman not found on PATH.' }) + // No point probing a daemon whose CLI is absent. + expect(captured.map(argvOf)).not.toContain('podman info') + }) + + it("surfaces the daemon's own error when the runtime is not ready", async () => { + script = [{ match: /podman info/, result: { code: 1, stderr: 'cannot connect to Podman socket\n' } }] + expect(await new PodmanDriver().detect()).toMatchObject({ + available: false, + reason: 'cannot connect to Podman socket', + }) + }) + + it('falls back to a generic reason when the failing probe said nothing', async () => { + script = [{ match: /podman info/, result: { code: 1 } }] + expect(await new PodmanDriver().detect()).toMatchObject({ reason: 'Podman runtime is not ready.' }) + }) + + it('uses the separate version binary and subcommand prefix configured by Lima', async () => { + await new LimaDriver().detect() + expect(captured.map(argvOf)).toEqual(['limactl --version', 'lima nerdctl info']) + }) +}) + +describe('DockerDriver AppArmor probing', () => { + it('trusts the daemon, not the host, and marks AppArmor supported when it reports the LSM', async () => { + script = [{ match: /SecurityOptions/, result: { stdout: '["name=seccomp","name=apparmor"]' } }] + const driver = new DockerDriver() + await driver.detect() + expect(driver.capabilities.apparmor).toEqual({ support: 'supported' }) + }) + + it('marks AppArmor unenforced when the daemon does not report it', async () => { + script = [{ match: /SecurityOptions/, result: { stdout: '["name=seccomp"]' } }] + const driver = new DockerDriver() + await driver.detect() + expect(driver.capabilities.apparmor).toMatchObject({ support: 'caveated', enforced: false }) + expect(driver.capabilities.apparmor.note).toContain('does not report AppArmor support') + }) + + it('keeps the fail-closed default when the probe itself fails', async () => { + script = [{ match: /SecurityOptions/, result: { code: 1 } }] + const driver = new DockerDriver() + await driver.detect() + // Claiming enforcement we could not verify is the failure mode that matters. + expect(driver.capabilities.apparmor).toMatchObject({ support: 'caveated', enforced: false }) + expect(driver.capabilities.apparmor.note).toContain('could not be confirmed') + }) + + it('does not probe capabilities at all when Docker is unavailable', async () => { + script = [{ match: /docker --version/, result: { spawnError: true } }] + await new DockerDriver().detect() + expect(captured.map(argvOf).some((a) => a.includes('SecurityOptions'))).toBe(false) + }) +}) + +describe('OrbstackDriver.detect', () => { + it('accepts Docker when the active context is OrbStack', async () => { + script = [ + { match: /docker --version/, result: { stdout: 'Docker version 27.0.3' } }, + { match: /docker context show/, result: { stdout: 'orbstack\n' } }, + ] + expect(await new OrbstackDriver().detect()).toEqual({ available: true, version: 'Docker version 27.0.3' }) + }) + + it('falls back to matching `docker info` when the context name is uninformative', async () => { + script = [ + { match: /docker context show/, result: { stdout: 'default\n' } }, + { match: /docker info --format \{\{json \.\}\}/, result: { stdout: '{"Name":"orbstack"}' } }, + ] + expect(await new OrbstackDriver().detect()).toMatchObject({ available: true }) + }) + + it('reports unavailable when Docker is running but is not OrbStack', async () => { + script = [ + { match: /docker context show/, result: { stdout: 'desktop-linux\n' } }, + { match: /docker info --format \{\{json \.\}\}/, result: { stdout: '{"Name":"docker-desktop"}' } }, + ] + expect(await new OrbstackDriver().detect()).toMatchObject({ + available: false, + reason: 'Docker is running but the active context is not OrbStack.', + }) + }) + + it('short-circuits when Docker itself is unavailable', async () => { + script = [{ match: /docker --version/, result: { spawnError: true } }] + expect(await new OrbstackDriver().detect()).toMatchObject({ available: false }) + expect(captured.map(argvOf).some((a) => a.includes('context show'))).toBe(false) + }) + + it('keeps the fail-closed AppArmor default when the daemon probe fails', async () => { + script = [ + { match: /SecurityOptions/, result: { code: 1 } }, + { match: /docker context show/, result: { stdout: 'orbstack' } }, + ] + const driver = new OrbstackDriver() + await driver.detect() + expect(driver.capabilities.apparmor).toMatchObject({ support: 'caveated', enforced: false }) + }) + + it('probes the daemon for AppArmor like the Docker driver does', async () => { + script = [ + { match: /SecurityOptions/, result: { stdout: '["name=apparmor"]' } }, + { match: /docker context show/, result: { stdout: 'orbstack' } }, + ] + const driver = new OrbstackDriver() + await driver.detect() + expect(driver.capabilities.apparmor).toEqual({ support: 'supported' }) + }) +}) + +describe('CliDriver.build', () => { + it('composes the build argv and reads the image id back via inspect', async () => { + script = [{ match: /image inspect/, result: { stdout: 'sha256:abc\n' } }] + const res = await new DockerDriver().build({ + containerfilePath: '/state/Containerfile', + contextDir: '/state', + tag: 'dcw/env1:latest', + platform: 'linux/amd64', + noCache: true, + buildArgs: { FOO: 'bar' }, + }) + + expect(res).toEqual({ imageId: 'sha256:abc' }) + expect(argvOf(inherited[0]!)).toBe( + 'docker build -f /state/Containerfile -t dcw/env1:latest --platform linux/amd64 --no-cache --build-arg FOO=bar /state', + ) + }) + + it('omits the optional flags when they are not requested', async () => { + await new DockerDriver().build({ containerfilePath: '/c', contextDir: '/ctx', tag: 't' }) + expect(argvOf(inherited[0]!)).toBe('docker build -f /c -t t /ctx') + }) + + it('falls back to the tag when the image id cannot be inspected', async () => { + script = [{ match: /image inspect/, result: { code: 1 } }] + expect(await new DockerDriver().build({ containerfilePath: '/c', contextDir: '/ctx', tag: 'dcw/x:latest' })).toEqual({ + imageId: 'dcw/x:latest', + }) + }) + + it('raises with the exit code when the build fails', async () => { + inheritCode = 2 + await expect( + new DockerDriver().build({ containerfilePath: '/c', contextDir: '/ctx', tag: 't' }), + ).rejects.toThrow('Docker build failed (exit 2).') + }) +}) + +describe('CliDriver.run', () => { + it('composes name, labels, hardening flags, workdir, env and command in order', async () => { + script = [{ match: /docker run/, result: { stdout: 'cid-123\n' } }] + const res = await new DockerDriver().run({ + image: 'dcw/env1:latest', + name: 'dcw-env1', + labels: { 'dcw.env': 'env1' }, + flags: ['--cap-drop=ALL', '-v', '/ws:/workspace'], + workdir: '/workspace', + env: { FOO: 'bar' }, + detach: true, + command: ['sleep', 'infinity'], + }) + + expect(res).toEqual({ containerId: 'cid-123' }) + expect(argvOf(captured.at(-1)!)).toBe( + 'docker run -d --name dcw-env1 --label dcw.env=env1 --cap-drop=ALL -v /ws:/workspace -w /workspace -e FOO=bar dcw/env1:latest sleep infinity', + ) + }) + + it('omits -d, -w and the command when they are not requested', async () => { + await new DockerDriver().run({ image: 'img', name: 'n', flags: [], detach: false }) + expect(argvOf(captured.at(-1)!)).toBe('docker run --name n img') + }) + + it('takes the LAST stdout line as the container id, past any daemon chatter', async () => { + script = [{ match: /docker run/, result: { stdout: 'Unable to find image locally\ncid-999\n' } }] + expect(await new DockerDriver().run({ image: 'i', name: 'n', flags: [], detach: true })).toEqual({ + containerId: 'cid-999', + }) + }) + + it('raises with the engine stderr when the run fails', async () => { + script = [{ match: /docker run/, result: { code: 125, stderr: 'port is already allocated\n' } }] + await expect(new DockerDriver().run({ image: 'i', name: 'n', flags: [], detach: true })).rejects.toThrow( + 'Docker run failed: port is already allocated', + ) + }) + + it('still names the failing action when the engine said nothing', async () => { + script = [{ match: /docker run/, result: { code: 1 } }] + await expect(new DockerDriver().run({ image: 'i', name: 'n', flags: [], detach: true })).rejects.toThrow( + 'Docker run failed.', + ) + }) +}) + +describe('CliDriver.exec', () => { + const spec = { container: 'cid-1', cmd: ['ls', '-la'], interactive: true, tty: true } + + it('passes -i/-t and the user, then the container and command', async () => { + await new DockerDriver().exec({ ...spec, user: 'vscode' }) + expect(argvOf(inherited[0]!)).toBe('docker exec -i -t --user vscode cid-1 ls -la') + }) + + it('omits -i/-t when there is no terminal', async () => { + await new DockerDriver().exec({ ...spec, interactive: false, tty: false }) + expect(argvOf(inherited[0]!)).toBe('docker exec cid-1 ls -la') + }) + + it('passes -e NAME value-less so secrets never enter argv or the host process table', async () => { + await new DockerDriver().exec({ ...spec, env: { GITHUB_TOKEN: 'ghp_secret' } }) + const call = inherited[0]! + expect(call.args.join(' ')).toContain('-e GITHUB_TOKEN') + expect(call.args.join(' ')).not.toContain('ghp_secret') + // The value reaches the engine by inheritance through our own environment. + expect((call.opts?.env as NodeJS.ProcessEnv).GITHUB_TOKEN).toBe('ghp_secret') + }) + + it('leaves the process environment untouched when there are no env forwards', async () => { + await new DockerDriver().exec(spec) + expect(inherited[0]!.opts?.env).toBeUndefined() + await new DockerDriver().exec({ ...spec, env: {} }) + expect(inherited[1]!.opts?.env).toBeUndefined() + }) + + it('returns the exit code of the exec\'d process', async () => { + inheritCode = 42 + expect(await new DockerDriver().exec(spec)).toBe(42) + }) +}) + +describe('CliDriver.execCapture', () => { + it('feeds stdin when input is supplied', async () => { + await new DockerDriver().execCapture({ container: 'c', cmd: ['sh'], interactive: true, tty: false }, 'key-data') + expect(captured[0]!.opts).toEqual({ input: 'key-data' }) + }) + + it('passes neither input nor env when neither is supplied', async () => { + await new DockerDriver().execCapture({ container: 'c', cmd: ['sh'], interactive: false, tty: false }) + expect(captured[0]!.opts).toEqual({}) + }) + + it('merges forwarded env vars into the captured process environment', async () => { + await new DockerDriver().execCapture({ + container: 'c', + cmd: ['env'], + interactive: false, + tty: false, + env: { A: '1' }, + }) + expect((captured[0]!.opts?.env as NodeJS.ProcessEnv).A).toBe('1') + }) +}) + +describe('CliDriver.stop / rm', () => { + it('stops by id', async () => { + await new DockerDriver().stop('cid-1') + expect(argvOf(captured[0]!)).toBe('docker stop cid-1') + }) + + it('raises with the engine stderr when the stop fails', async () => { + script = [{ match: /docker stop/, result: { code: 1, stderr: 'no such container\n' } }] + await expect(new DockerDriver().stop('cid-1')).rejects.toThrow('Docker stop failed: no such container') + }) + + it('removes, with and without --force', async () => { + const driver = new DockerDriver() + await driver.rm('cid-1') + await driver.rm('cid-1', { force: true }) + expect(captured.map(argvOf)).toEqual(['docker rm cid-1', 'docker rm -f cid-1']) + }) + + it('raises when the removal fails', async () => { + script = [{ match: /docker rm/, result: { code: 1, stderr: 'container is running\n' } }] + await expect(new DockerDriver().rm('cid-1')).rejects.toThrow('Docker rm failed: container is running') + }) + + it('uses the engine\'s own verb where it differs (Lima keeps docker verbs behind nerdctl)', async () => { + await new LimaDriver().rm('cid-1', { force: true }) + expect(argvOf(captured[0]!)).toBe('lima nerdctl rm -f cid-1') + }) +}) + +describe('CliDriver.ps', () => { + const row = (over: Record = {}) => + JSON.stringify({ ID: 'c1', Names: 'dcw-env1', Image: 'img', Status: 'Up 2m', Labels: 'dcw.env=env1,x=y', ...over }) + + it('requests NDJSON, includes stopped containers by default, and parses the rows', async () => { + script = [{ match: /docker ps/, result: { stdout: `${row()}\n` } }] + const res = await new DockerDriver().ps() + expect(argvOf(captured[0]!)).toBe('docker ps -a --format {{json .}}') + expect(res).toEqual([ + { id: 'c1', name: 'dcw-env1', image: 'img', status: 'Up 2m', labels: { 'dcw.env': 'env1', x: 'y' } }, + ]) + }) + + it('passes a label filter through to the engine and can exclude stopped containers', async () => { + await new DockerDriver().ps({ label: 'dcw.env=env1', all: false }) + expect(argvOf(captured[0]!)).toBe('docker ps --filter label=dcw.env=env1 --format {{json .}}') + }) + + it('returns an empty list rather than throwing when the engine is unreachable', async () => { + script = [{ match: /docker ps/, result: { code: 1, stderr: 'daemon down' } }] + expect(await new DockerDriver().ps()).toEqual([]) + }) +}) + +describe('parsePsJson', () => { + it('accepts both docker and nerdctl field spellings', async () => { + const rows = parsePsJson( + [ + JSON.stringify({ ID: 'a', Names: 'na', Image: 'ia', Status: 'Up', Labels: '' }), + JSON.stringify({ Id: 'b', Name: 'nb', Image: 'ib', State: 'running', Labels: { k: 1 } }), + ].join('\n'), + ) + expect(rows).toEqual([ + { id: 'a', name: 'na', image: 'ia', status: 'Up', labels: {} }, + { id: 'b', name: 'nb', image: 'ib', status: 'running', labels: { k: '1' } }, + ]) + }) + + it('skips blank and malformed lines instead of failing the whole listing', async () => { + expect(parsePsJson('\n \nnot json\n{"ID":"a"}\n')).toEqual([ + { id: 'a', name: '', image: '', status: '', labels: {} }, + ]) + }) + + it('ignores label fragments with no key', async () => { + const [row0] = parsePsJson(JSON.stringify({ ID: 'a', Labels: '=novalue,ok=1,bare' })) + expect(row0?.labels).toEqual({ ok: '1' }) + }) +}) + +describe('CliDriver.runOnce', () => { + it('runs a throwaway container with the extra flags before the image', async () => { + script = [{ match: /docker run --rm/, result: { stdout: 'report\n', code: 0 } }] + const res = await new DockerDriver().runOnce('dcw/env1:latest', ['cat', '/report'], ['--network=none']) + expect(argvOf(captured[0]!)).toBe('docker run --rm --network=none dcw/env1:latest cat /report') + expect(res).toEqual({ stdout: 'report\n', code: 0 }) + }) + + it('defaults to no extra flags', async () => { + await new DockerDriver().runOnce('img', ['true']) + expect(argvOf(captured[0]!)).toBe('docker run --rm img true') + }) +}) + +describe('CliDriver.logs', () => { + it('streams logs, forwarding --follow and --tail', async () => { + await new DockerDriver().logs('cid-1', { follow: true, tail: 100 }) + expect(argvOf(inherited[0]!)).toBe('docker logs -f --tail 100 cid-1') + }) + + it('omits both when neither is requested', async () => { + await new DockerDriver().logs('cid-1') + expect(argvOf(inherited[0]!)).toBe('docker logs cid-1') + }) + + it('forwards --tail 0', async () => { + await new DockerDriver().logs('cid-1', { tail: 0 }) + expect(argvOf(inherited[0]!)).toBe('docker logs --tail 0 cid-1') + }) + + it('returns the exit code of the stream', async () => { + inheritCode = 1 + expect(await new DockerDriver().logs('cid-1')).toBe(1) + }) +}) diff --git a/packages/core/test/unit/cmd-agent.test.ts b/packages/core/test/unit/cmd-agent.test.ts new file mode 100644 index 0000000..1c8fccd --- /dev/null +++ b/packages/core/test/unit/cmd-agent.test.ts @@ -0,0 +1,265 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { FakeDriver } from '../engine/fake-driver.js' +import { manifest, useTempState } from '../helpers/fixtures.js' +import { runCommand } from '../helpers/command.js' + +let driver: FakeDriver +/** Scripted `execCapture` results, consumed in call order. */ +let captures: Array<{ code: number; stdout?: string; stderr?: string }> + +vi.mock('../../src/engine/resolver.js', async (orig) => { + const actual = await orig() + return { + ...actual, + resolveEngine: async () => ({ driver, detect: { available: true }, platform: { supported: true } }), + } +}) + +vi.mock('../../src/engine/host.js', async (orig) => { + const actual = await orig() + // Skip the real `sw_vers` probe: it spawns a subprocess on every resolve. + return { ...actual, detectHost: async () => ({ os: 'linux', arch: 'x64' }) } +}) + +const { default: Agent, assessAirgap } = await import('../../src/commands/agent.js') +const { saveManifest } = await import('../../src/state/store.js') + +let state: Awaited> + +const started = (over: Record = {}, spec: Record = {}) => + manifest({ + name: 'env1', + engine: 'docker', + spec: { name: 'env1', engine: 'auto', selections: {}, hardening: [], ssh: true, ...spec } as never, + container: { id: 'cid-1', name: 'dcw-env1', status: 'running', appliedFlags: [], ...over } as never, + }) + +beforeEach(async () => { + state = await useTempState('dcw-agent-') + captures = [] + driver = new FakeDriver({ name: 'docker' }) + vi.spyOn(driver, 'execCapture').mockImplementation(async (spec) => { + driver.execs.push(spec) + const next = captures.shift() ?? { code: 0 } + return { code: next.code, stdout: next.stdout ?? '', stderr: next.stderr ?? '', spawnError: false } + }) + delete process.env.ANTHROPIC_API_KEY + delete process.env.OPENAI_API_KEY +}) + +afterEach(async () => { + await state.cleanup() + vi.restoreAllMocks() +}) + +describe('assessAirgap', () => { + it("returns 'none' when no air-gap was ever requested", () => { + expect(assessAirgap(started())).toBe('none') + }) + + it("returns 'enforced' only when --network=none is in the flags the container actually got", () => { + expect(assessAirgap(started({ appliedFlags: ['--network=none'] }, { hardening: ['network-none'] }))).toBe('enforced') + }) + + it("returns 'dropped' when the engine recorded the air-gap as dropped", () => { + expect( + assessAirgap(started({ appliedFlags: ['--cap-drop=ALL'], droppedHardening: ['network-none'] }, { hardening: ['network-none'] })), + ).toBe('dropped') + }) + + it("returns 'dropped' when the applied flags simply lack the air-gap", () => { + expect(assessAirgap(started({ appliedFlags: ['--cap-drop=ALL'] }, { hardening: ['network-none'] }))).toBe('dropped') + }) + + it("returns 'unknown' — never 'enforced' — when there is no record of what was applied", () => { + // Absence of evidence is not evidence of enforcement. + expect(assessAirgap(started({ appliedFlags: [] }, { hardening: ['network-none'] }))).toBe('unknown') + expect(assessAirgap(started({ appliedFlags: undefined }, { hardening: ['network-none'] }))).toBe('unknown') + expect(assessAirgap(manifest({ name: 'env1', spec: { name: 'env1', engine: 'auto', selections: {}, hardening: ['network-none'], ssh: true } }))).toBe('unknown') + }) +}) + +describe('dcw agent', () => { + it('runs the agent through an interactive zsh so the nvm PATH is loaded', async () => { + await saveManifest(started()) + const { exitCode } = await runCommand(Agent, { argv: ['claude'], flags: {} }) + + expect(exitCode).toBe(0) + // First exec is the presence probe, second is the agent itself. + expect(driver.execs[0]?.cmd).toEqual(['zsh', '-ic', 'command -v claude']) + expect(driver.execs[1]?.cmd).toEqual(['zsh', '-ic', 'exec claude "$@"', 'claude']) + }) + + it('forwards trailing arguments as positionals so they are not re-split', async () => { + await saveManifest(started()) + await runCommand(Agent, { argv: ['opencode', 'run', 'review this contract'], flags: {} }) + expect(driver.execs[1]?.cmd).toEqual([ + 'zsh', + '-ic', + 'exec opencode "$@"', + 'opencode', + 'run', + 'review this contract', + ]) + }) + + it('treats the second token as the environment only when it names one', async () => { + await saveManifest(started()) + await runCommand(Agent, { argv: ['claude', 'env1', '--resume'], flags: {} }) + expect(driver.execs[1]?.cmd.slice(3)).toEqual(['claude', '--resume']) + }) + + it('treats a non-environment second token as an agent argument', async () => { + await saveManifest(started()) + await runCommand(Agent, { argv: ['claude', 'chat'], flags: {} }) + expect(driver.execs[1]?.cmd.slice(3)).toEqual(['claude', 'chat']) + }) + + it('never treats a leading flag-like token as an environment name', async () => { + await saveManifest(started()) + await runCommand(Agent, { argv: ['claude', '--resume'], flags: {} }) + expect(driver.execs[1]?.cmd.slice(3)).toEqual(['claude', '--resume']) + }) + + it('rejects an unknown agent type', async () => { + await saveManifest(started()) + await expect(runCommand(Agent, { argv: ['gemini'], flags: {} })).rejects.toMatchObject({ code: 'E_VALIDATION' }) + }) + + it("forwards the agent's provider key from the host", async () => { + await saveManifest(started()) + process.env.ANTHROPIC_API_KEY = 'sk-ant-x' + const { warns } = await runCommand(Agent, { argv: ['claude'], flags: {} }) + expect(driver.execs[1]?.env).toEqual({ ANTHROPIC_API_KEY: 'sk-ant-x' }) + expect(warns).toEqual([]) + }) + + it('forwards extra variables named with --env, by name only', async () => { + await saveManifest(started()) + process.env.ANTHROPIC_API_KEY = 'sk-ant-x' + process.env.GITHUB_TOKEN = 'ghp_x' + try { + await runCommand(Agent, { argv: ['claude'], flags: { env: ['GITHUB_TOKEN'] } }) + expect(driver.execs[1]?.env).toEqual({ ANTHROPIC_API_KEY: 'sk-ant-x', GITHUB_TOKEN: 'ghp_x' }) + } finally { + delete process.env.GITHUB_TOKEN + } + }) + + it('warns when no provider key is present on the host', async () => { + await saveManifest(started()) + const { warns } = await runCommand(Agent, { argv: ['codex'], flags: {} }) + expect(warns).toEqual(['No API key found on the host (OPENAI_API_KEY); codex will rely on its own login.']) + }) + + it('accepts either provider key for opencode', async () => { + await saveManifest(started()) + process.env.OPENAI_API_KEY = 'sk-oai' + const { warns } = await runCommand(Agent, { argv: ['opencode'], flags: {} }) + expect(warns).toEqual([]) + expect(driver.execs[1]?.env).toEqual({ OPENAI_API_KEY: 'sk-oai' }) + }) + + it('refuses to run in an enforced air-gap under --strict, and warns otherwise', async () => { + await saveManifest(started({ appliedFlags: ['--network=none'] }, { hardening: ['network-none'] })) + process.env.ANTHROPIC_API_KEY = 'sk-ant-x' + + const { warns } = await runCommand(Agent, { argv: ['claude'], flags: {} }) + expect(warns[0]).toContain('cannot reach its API') + + await expect(runCommand(Agent, { argv: ['claude'], flags: { strict: true } })).rejects.toMatchObject({ + code: 'E_STRICT_HARDENING', + }) + }) + + it('warns loudly when a requested air-gap was dropped, because credentials are about to be forwarded', async () => { + await saveManifest( + started({ appliedFlags: ['--cap-drop=ALL'], droppedHardening: ['network-none'] }, { hardening: ['network-none'] }), + ) + process.env.ANTHROPIC_API_KEY = 'sk-ant-x' + + const { warns } = await runCommand(Agent, { argv: ['claude'], flags: {} }) + expect(warns[0]).toContain('this container HAS network access') + + await expect(runCommand(Agent, { argv: ['claude'], flags: { strict: true } })).rejects.toMatchObject({ + code: 'E_STRICT_HARDENING', + }) + }) + + it('treats an unrecorded air-gap as unsafe rather than assuming it holds', async () => { + await saveManifest(started({ appliedFlags: [] }, { hardening: ['network-none'] })) + process.env.ANTHROPIC_API_KEY = 'sk-ant-x' + const { warns } = await runCommand(Agent, { argv: ['claude'], flags: {} }) + expect(warns[0]).toContain('no record that it was applied') + + await expect(runCommand(Agent, { argv: ['claude'], flags: { strict: true } })).rejects.toMatchObject({ + code: 'E_STRICT_HARDENING', + }) + }) + + it('refuses a container with dropped hardening under --strict before touching it', async () => { + await saveManifest(started({ droppedHardening: ['drop-caps'] })) + await expect(runCommand(Agent, { argv: ['claude'], flags: { strict: true } })).rejects.toMatchObject({ + code: 'E_STRICT_HARDENING', + }) + // Nothing was installed, nothing was exec'd, no credentials were forwarded. + expect(driver.execs).toEqual([]) + }) + + it('points at --install when the agent binary is missing', async () => { + await saveManifest(started()) + captures = [{ code: 1 }] + await expect(runCommand(Agent, { argv: ['claude'], flags: {} })).rejects.toMatchObject({ + code: 'E_NOT_FOUND', + message: expect.stringContaining('--install'), + }) + }) + + it('installs the agent on demand with --install', async () => { + await saveManifest(started()) + captures = [{ code: 1 }, { code: 0 }] + const { logs, exitCode } = await runCommand(Agent, { argv: ['claude'], flags: { install: true } }) + + expect(logs).toEqual(['Installing claude (npm install -g @anthropic-ai/claude-code)…']) + expect(driver.execs[1]?.cmd).toEqual(['zsh', '-ic', 'npm install -g @anthropic-ai/claude-code']) + expect(exitCode).toBe(0) + }) + + it('reports the installer stderr when the on-demand install fails', async () => { + await saveManifest(started()) + captures = [{ code: 1 }, { code: 1, stderr: 'E404 not found\n' }] + await expect(runCommand(Agent, { argv: ['claude'], flags: { install: true } })).rejects.toMatchObject({ + message: 'Failed to install @anthropic-ai/claude-code: E404 not found', + }) + }) + + it('falls back to installer stdout when stderr is empty', async () => { + await saveManifest(started()) + captures = [{ code: 1 }, { code: 1, stdout: 'no space left\n' }] + await expect(runCommand(Agent, { argv: ['claude'], flags: { install: true } })).rejects.toMatchObject({ + message: 'Failed to install @anthropic-ai/claude-code: no space left', + }) + }) + + it('raises E_NOT_FOUND when the environment has no container', async () => { + await saveManifest(manifest({ name: 'env1' })) + await expect(runCommand(Agent, { argv: ['claude'], flags: {} })).rejects.toMatchObject({ + code: 'E_NOT_FOUND', + message: expect.stringContaining('dcw up env1'), + }) + }) + + it('falls back to the container name when no id was recorded', async () => { + await saveManifest(started({ id: undefined })) + await runCommand(Agent, { argv: ['claude'], flags: {} }) + expect(driver.execs[0]?.container).toBe('dcw-env1') + }) + + it('propagates the agent exit code', async () => { + await saveManifest(started()) + driver = new FakeDriver({ name: 'docker', execResult: 130 }) + vi.spyOn(driver, 'execCapture').mockResolvedValue({ code: 0, stdout: '', stderr: '', spawnError: false }) + const { exitCode } = await runCommand(Agent, { argv: ['claude'], flags: {} }) + expect(exitCode).toBe(130) + }) +}) diff --git a/packages/core/test/unit/cmd-attach.test.ts b/packages/core/test/unit/cmd-attach.test.ts new file mode 100644 index 0000000..3ac5baa --- /dev/null +++ b/packages/core/test/unit/cmd-attach.test.ts @@ -0,0 +1,480 @@ +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { caps } from '../../src/engine/drivers/capabilities.js' +import { FakeDriver } from '../engine/fake-driver.js' +import { manifest, useTempState } from '../helpers/fixtures.js' +import { runCommand } from '../helpers/command.js' + +let driver: FakeDriver +let running = false + +const findFreePort = vi.fn(async () => 54321) +const isContainerRunning = vi.fn(async () => running) +const resolveDcwInvocation = vi.fn(async (name: string) => `dcw ssh-proxy ${name}`) +const detectEditor = vi.fn(async () => undefined as string | undefined) +const launchEditor = vi.fn(async (_opts: { editor: string; alias: string; folder: string }) => true) +const provisionContainerSsh = vi.fn(async (_o: unknown) => {}) +const startSshDaemon = vi.fn(async (_d: unknown, _c: string) => {}) + +vi.mock('../../src/cli/context.js', async (orig) => { + const actual = await orig() + return { + ...actual, + resolveEngineFor: vi.fn(async () => ({ driver, engineName: driver.name, capabilities: driver.capabilities })), + } +}) +vi.mock('../../src/core/ssh/attach.js', async (orig) => { + const actual = await orig() + return { + ...actual, + findFreePort: () => findFreePort(), + isContainerRunning: () => isContainerRunning(), + resolveDcwInvocation: (n: string) => resolveDcwInvocation(n), + } +}) +vi.mock('../../src/core/ssh/editors.js', async (orig) => { + const actual = await orig() + return { ...actual, detectEditor: () => detectEditor(), launchEditor: (o: never) => launchEditor(o) } +}) +vi.mock('../../src/core/ssh/provision.js', () => ({ + provisionContainerSsh: (o: unknown) => provisionContainerSsh(o), + startSshDaemon: (d: unknown, c: string) => startSshDaemon(d, c), +})) +vi.mock('../../src/core/ssh/keys.js', async (orig) => { + const actual = await orig() + return { + ...actual, + // The real one shells out to ssh-keygen; the command only needs the paths. + ensureKeypair: async () => ({ + privateKeyPath: `${process.env.XDG_CONFIG_HOME}/dcw/ssh/id_ed25519`, + publicKeyPath: `${process.env.XDG_CONFIG_HOME}/dcw/ssh/id_ed25519.pub`, + publicKey: 'ssh-ed25519 AAAA dcw-attach', + }), + } +}) + +const { default: Attach } = await import('../../src/commands/attach.js') +const { loadManifest, saveManifest } = await import('../../src/state/store.js') + +let state: Awaited> +let prevHome: string | undefined + +const base = { folder: '/workspace', print: false, strict: false, 'no-input': false, yes: false } + +const started = (over: Record = {}, spec: Record = {}) => + manifest({ + name: 'env1', + engine: 'docker', + spec: { name: 'env1', engine: 'auto', selections: {}, hardening: [], ssh: true, ...spec } as never, + image: { tag: 'dcw/env1:latest', containerfileHash: 'h', imageId: 'sha256:1' }, + container: { id: 'cid-1', name: 'dcw-env1', status: 'running', appliedFlags: ['--cap-drop=ALL'], ...over } as never, + }) + +async function sshConfig(): Promise { + return fs.readFile(path.join(os.homedir(), '.ssh', 'config'), 'utf8') +} + +beforeEach(async () => { + state = await useTempState('dcw-attach-') + prevHome = process.env.HOME + // os.homedir() honors $HOME on POSIX, so ~/.ssh/config lands in the temp dir. + process.env.HOME = path.join(state.dir, 'home') + await fs.mkdir(process.env.HOME, { recursive: true }) + driver = new FakeDriver({ name: 'docker', displayName: 'Docker' }) + running = false + for (const m of [findFreePort, isContainerRunning, resolveDcwInvocation, detectEditor, launchEditor, provisionContainerSsh, startSshDaemon]) { + m.mockClear() + } + detectEditor.mockResolvedValue(undefined) + launchEditor.mockResolvedValue(true) +}) + +afterEach(async () => { + if (prevHome === undefined) delete process.env.HOME + else process.env.HOME = prevHome + await state.cleanup() + vi.clearAllMocks() +}) + +describe('dcw attach (validation)', () => { + it('rejects a relative --folder before touching any environment', async () => { + // Zed's remote form is a URL, so a relative folder yields a malformed target. + await expect(runCommand(Attach, { flags: { ...base, folder: 'work' }, json: true })).rejects.toMatchObject({ + code: 'E_VALIDATION', + message: expect.stringContaining('--folder /workspace'), + }) + }) + + it('refuses an environment created with --no-ssh', async () => { + await saveManifest(started({}, { ssh: false })) + await expect(runCommand(Attach, { args: { name: 'env1' }, flags: base, json: true })).rejects.toMatchObject({ + code: 'E_VALIDATION', + message: expect.stringContaining('--no-ssh'), + }) + }) + + it('refuses --port on an air-gapped environment WITHOUT destroying the running container', async () => { + await saveManifest(started({}, { hardening: ['network-none'] })) + running = true + await expect( + runCommand(Attach, { args: { name: 'env1' }, flags: { ...base, port: 0 }, json: true }), + ).rejects.toMatchObject({ code: 'E_VALIDATION', message: expect.stringContaining('without --port') }) + expect(driver.rms).toEqual([]) + expect(driver.runs).toEqual([]) + }) +}) + +describe('dcw attach (exec proxy mode)', () => { + it('reuses a running container and writes a ProxyCommand ssh config block', async () => { + await saveManifest(started()) + running = true + + const { result } = await runCommand>(Attach, { + args: { name: 'env1' }, + flags: base, + json: true, + }) + + expect(result).toMatchObject({ + name: 'env1', + engine: 'docker', + host: 'dcw-env1', + mode: 'exec', + port: undefined, + user: 'vscode', + folder: '/workspace', + ssh: 'ssh dcw-env1', + launched: false, + }) + expect(driver.runs).toEqual([]) + const cfg = await sshConfig() + expect(cfg).toContain('Host dcw-env1') + expect(cfg).toContain('ProxyCommand dcw ssh-proxy env1') + // No listening daemon is needed when SSH rides the engine's exec channel. + expect(startSshDaemon).not.toHaveBeenCalled() + }) + + it('starts the container when it is not running', async () => { + await saveManifest(started()) + running = false + await runCommand(Attach, { args: { name: 'env1' }, flags: base, json: true }) + expect(driver.runs).toHaveLength(1) + expect(driver.runs[0]?.flags.some((f) => f.startsWith('-p'))).toBe(false) + }) + + it('mounts --workspace when it has to start the container', async () => { + await saveManifest(started()) + await runCommand(Attach, { args: { name: 'env1' }, flags: { ...base, workspace: '/host/ws' }, json: true }) + expect(driver.runs[0]?.flags).toContain('/host/ws:/workspace') + }) + + it("resolves the engine from the spec when the manifest has none recorded yet", async () => { + await saveManifest( + manifest({ + name: 'env1', + engine: null, + spec: { name: 'env1', engine: 'podman', selections: {}, hardening: [], ssh: true }, + image: { tag: 'dcw/env1:latest', containerfileHash: 'h', imageId: 'sha256:1' }, + container: { id: 'cid-1', name: 'dcw-env1', status: 'running', appliedFlags: [] } as never, + }), + ) + running = true + const { result } = await runCommand<{ engine: string }>(Attach, { + args: { name: 'env1' }, + flags: base, + json: true, + }) + expect(result?.engine).toBe('docker') + }) + + it('addresses the container by name when no id was recorded', async () => { + await saveManifest(started({ id: undefined })) + running = true + await runCommand(Attach, { args: { name: 'env1' }, flags: base, json: true }) + expect(provisionContainerSsh).toHaveBeenCalledWith(expect.objectContaining({ container: 'dcw-env1' })) + }) + + it('installs the attach key and records the ssh mode on the manifest', async () => { + await saveManifest(started()) + running = true + await runCommand(Attach, { args: { name: 'env1' }, flags: base, json: true }) + + expect(provisionContainerSsh).toHaveBeenCalledWith( + expect.objectContaining({ container: 'cid-1', hostAlias: 'dcw-env1', publicKey: 'ssh-ed25519 AAAA dcw-attach' }), + ) + expect((await loadManifest('env1'))?.container?.ssh).toEqual({ mode: 'exec' }) + }) +}) + +describe('dcw attach (published-port mode)', () => { + it('auto-allocates a free port with --port 0 and starts a listening daemon', async () => { + await saveManifest(started()) + const { result } = await runCommand<{ mode: string; port: number }>(Attach, { + args: { name: 'env1' }, + flags: { ...base, port: 0 }, + json: true, + }) + + expect(result).toMatchObject({ mode: 'port', port: 54321 }) + expect(driver.runs[0]?.flags).toContain('127.0.0.1:54321:2222') + expect(startSshDaemon).toHaveBeenCalledWith(driver, 'cid-dcw-env1') + const cfg = await sshConfig() + expect(cfg).toContain('HostName localhost') + expect(cfg).toContain('Port 54321') + expect(cfg).not.toContain('ProxyCommand') + }) + + it('uses an explicit --port verbatim', async () => { + await saveManifest(started()) + const { result } = await runCommand<{ port: number }>(Attach, { + args: { name: 'env1' }, + flags: { ...base, port: 2222 }, + json: true, + }) + expect(result?.port).toBe(2222) + expect(findFreePort).not.toHaveBeenCalled() + }) + + it('reuses a running container already published on the requested port', async () => { + await saveManifest(started({ ssh: { mode: 'port', port: 2222 } })) + running = true + const { result } = await runCommand<{ port: number }>(Attach, { + args: { name: 'env1' }, + flags: { ...base, port: 2222 }, + json: true, + }) + expect(result?.port).toBe(2222) + expect(driver.runs).toEqual([]) + }) + + it('accepts any existing published port when --port 0 asks for "some" port', async () => { + await saveManifest(started({ ssh: { mode: 'port', port: 40000 } })) + running = true + const { result } = await runCommand<{ port: number }>(Attach, { + args: { name: 'env1' }, + flags: { ...base, port: 0 }, + json: true, + }) + expect(result?.port).toBe(40000) + expect(driver.runs).toEqual([]) + }) + + it('restarts the container when the running one is published on a different port', async () => { + await saveManifest(started({ ssh: { mode: 'port', port: 40000 } })) + running = true + await runCommand(Attach, { args: { name: 'env1' }, flags: { ...base, port: 2222 }, json: true }) + expect(driver.runs).toHaveLength(1) + expect(driver.runs[0]?.flags).toContain('127.0.0.1:2222:2222') + }) + + it('restarts the container when the running one is in exec mode', async () => { + await saveManifest(started({ ssh: { mode: 'exec' } })) + running = true + await runCommand(Attach, { args: { name: 'env1' }, flags: { ...base, port: 2222 }, json: true }) + expect(driver.runs).toHaveLength(1) + }) +}) + +describe('dcw attach (--strict)', () => { + it('refuses to hand an editor a reused container whose hardening was dropped', async () => { + await saveManifest(started({ droppedHardening: ['network-none'] })) + running = true + await expect( + runCommand(Attach, { args: { name: 'env1' }, flags: { ...base, strict: true }, json: true }), + ).rejects.toMatchObject({ code: 'E_STRICT_HARDENING' }) + // A refusal must leave no trace: no keys provisioned, no ssh config written. + expect(provisionContainerSsh).not.toHaveBeenCalled() + await expect(sshConfig()).rejects.toThrow() + }) + + it('judges a reused container by what was recorded at start, not by today\'s capability map', async () => { + // The container recorded a clean start; the engine map has since tightened. + await saveManifest(started()) + running = true + driver = new FakeDriver({ + name: 'docker', + displayName: 'Docker', + capabilities: caps({ capDrop: { support: 'unsupported', note: 'newly known limitation' } }), + }) + const { result } = await runCommand<{ hardening: { dropped: string[]; appliedFlags: string[] } }>(Attach, { + args: { name: 'env1' }, + flags: { ...base, strict: true }, + json: true, + }) + expect(result?.hardening).toEqual({ + appliedFlags: ['--cap-drop=ALL'], + warnings: [], + dropped: [], + unenforced: [], + }) + }) + + it('fails a --strict start before force-removing the existing container', async () => { + await saveManifest(started({ ssh: { mode: 'exec' } }, { hardening: ['apparmor'] })) + running = true + driver = new FakeDriver({ + name: 'docker', + displayName: 'Docker', + capabilities: caps({ apparmor: { support: 'unsupported', note: 'no LSM' } }), + }) + await expect( + runCommand(Attach, { args: { name: 'env1' }, flags: { ...base, port: 2222, strict: true }, json: true }), + ).rejects.toMatchObject({ code: 'E_STRICT_HARDENING' }) + expect(driver.rms).toEqual([]) + }) +}) + +describe('dcw attach (hardening report)', () => { + it('reports the fresh translation when it started the container', async () => { + await saveManifest(started({}, { hardening: ['apparmor'] })) + driver = new FakeDriver({ + name: 'docker', + displayName: 'Docker', + capabilities: caps({ apparmor: { support: 'unsupported', note: 'no LSM' } }), + }) + const { result } = await runCommand<{ hardening: { dropped: string[] } }>(Attach, { + args: { name: 'env1' }, + flags: base, + json: true, + }) + expect(result?.hardening.dropped).toEqual(['apparmor']) + }) + + it('reports an empty report for a reused container that recorded no flags', async () => { + await saveManifest(started({ appliedFlags: undefined })) + running = true + const { result } = await runCommand<{ hardening: unknown }>(Attach, { + args: { name: 'env1' }, + flags: base, + json: true, + }) + expect(result?.hardening).toEqual({ appliedFlags: [], warnings: [], dropped: [], unenforced: [] }) + }) + + it('falls back to the recorded container state when nothing was started or reused', async () => { + // No container record at all: the report is empty rather than absent. + await saveManifest(manifest({ name: 'env1', engine: 'docker' })) + running = true + const { result } = await runCommand<{ hardening: unknown }>(Attach, { + args: { name: 'env1' }, + flags: base, + json: true, + }) + expect(result?.hardening).toEqual({ appliedFlags: [], warnings: [], dropped: [], unenforced: [] }) + }) +}) + +describe('dcw attach (editor launching)', () => { + it('detects and launches an editor in human mode', async () => { + await saveManifest(started()) + running = true + detectEditor.mockResolvedValue('zed') + + const { result, logs } = await runCommand<{ editor?: string; launched: boolean }>(Attach, { + args: { name: 'env1' }, + flags: base, + json: false, + }) + + expect(launchEditor).toHaveBeenCalledWith({ editor: 'zed', alias: 'dcw-env1', folder: '/workspace' }) + expect(result).toMatchObject({ editor: 'zed', launched: true }) + expect(logs[0]).toBe('Ready: dcw-env1 (exec proxy) on Docker.') + expect(logs[1]).toBe('Launched Zed → /workspace.') + expect(logs.join('\n')).toContain('code --remote ssh-remote+dcw-env1 /workspace') + expect(logs.join('\n')).toContain('zed ssh://dcw-env1/workspace') + }) + + it('reports the published port in the ready line', async () => { + await saveManifest(started()) + const { logs } = await runCommand(Attach, { args: { name: 'env1' }, flags: { ...base, port: 0 }, json: false }) + expect(logs.some((l) => l === 'Ready: dcw-env1 (localhost:54321) on Docker.')).toBe(true) + }) + + it('raises when an explicitly requested editor is not installed', async () => { + await saveManifest(started()) + running = true + launchEditor.mockResolvedValue(false) + await expect( + runCommand(Attach, { args: { name: 'env1' }, flags: { ...base, editor: 'cursor' }, json: false }), + ).rejects.toThrow("Editor 'cursor' is not installed") + }) + + it('stays silent when an auto-detected editor fails to launch', async () => { + await saveManifest(started()) + running = true + detectEditor.mockResolvedValue('zed') + launchEditor.mockResolvedValue(false) + const { result } = await runCommand<{ launched: boolean }>(Attach, { + args: { name: 'env1' }, + flags: base, + json: false, + }) + expect(result?.launched).toBe(false) + }) + + it('launches nothing under --print, --json, --no-input or --editor none', async () => { + await saveManifest(started()) + running = true + detectEditor.mockResolvedValue('zed') + + for (const flags of [ + { ...base, print: true }, + { ...base, 'no-input': true }, + { ...base, editor: 'none' }, + ]) { + launchEditor.mockClear() + const { result } = await runCommand<{ editor?: string; launched: boolean }>(Attach, { + args: { name: 'env1' }, + flags, + json: false, + }) + expect(launchEditor).not.toHaveBeenCalled() + expect(result).toMatchObject({ editor: undefined, launched: false }) + } + + launchEditor.mockClear() + await runCommand(Attach, { args: { name: 'env1' }, flags: base, json: true }) + expect(launchEditor).not.toHaveBeenCalled() + }) + + it('prints nothing to stdout under --json', async () => { + await saveManifest(started()) + running = true + const { logs } = await runCommand(Attach, { args: { name: 'env1' }, flags: base, json: true }) + expect(logs).toEqual([]) + }) + + it('logs build and start lines, plus hardening warnings, when it starts the container', async () => { + await saveManifest(manifest({ name: 'env1', engine: 'docker', spec: { name: 'env1', engine: 'auto', selections: {}, hardening: ['apparmor'], ssh: true } })) + driver = new FakeDriver({ + name: 'docker', + displayName: 'Docker', + capabilities: caps({ apparmor: { support: 'unsupported', note: 'no LSM' } }), + }) + const { logs, warns } = await runCommand(Attach, { args: { name: 'env1' }, flags: base, json: false }) + expect(logs[0]).toBe('Built dcw/env1:latest.') + expect(logs[1]).toBe('Started dcw-env1 on Docker.') + expect(warns).toEqual(['dropped [apparmor]: no LSM']) + }) + + it('prefixes advisory hardening notes with "note" rather than "dropped"', async () => { + await saveManifest(manifest({ name: 'env1', engine: 'docker', spec: { name: 'env1', engine: 'auto', selections: {}, hardening: ['apparmor'], ssh: true } })) + driver = new FakeDriver({ + name: 'docker', + displayName: 'Docker', + capabilities: caps({ apparmor: { support: 'caveated', note: 'best effort only' } }), + }) + const { warns } = await runCommand(Attach, { args: { name: 'env1' }, flags: base, json: false }) + expect(warns).toEqual(['note [apparmor]: best effort only']) + }) + + it('skips the build line when the image is already up to date', async () => { + await saveManifest(manifest({ name: 'env1', engine: 'docker' })) + await runCommand(Attach, { args: { name: 'env1' }, flags: base, json: false }) + running = false + const { logs } = await runCommand(Attach, { args: { name: 'env1' }, flags: base, json: false }) + expect(logs.some((l) => l.startsWith('Built '))).toBe(false) + }) +}) diff --git a/packages/core/test/unit/cmd-build.test.ts b/packages/core/test/unit/cmd-build.test.ts new file mode 100644 index 0000000..d0f7d1b --- /dev/null +++ b/packages/core/test/unit/cmd-build.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { FakeDriver } from '../engine/fake-driver.js' +import { manifest, useTempState } from '../helpers/fixtures.js' +import { runCommand } from '../helpers/command.js' + +let driver: FakeDriver +const resolveCalls: Array<{ requested?: string; manifestEngine?: string }> = [] + +vi.mock('../../src/cli/context.js', async (orig) => { + const actual = await orig() + return { + ...actual, + resolveEngineFor: vi.fn(async (opts: { requested?: string; manifestEngine?: string }) => { + resolveCalls.push(opts) + return { driver, engineName: driver.name, capabilities: driver.capabilities } + }), + } +}) + +const { default: Build } = await import('../../src/commands/build.js') +const { saveManifest, loadManifest } = await import('../../src/state/store.js') + +let state: Awaited> + +beforeEach(async () => { + state = await useTempState('dcw-build-') + driver = new FakeDriver({ name: 'docker', displayName: 'Docker' }) + resolveCalls.length = 0 +}) + +afterEach(async () => { + await state.cleanup() + vi.clearAllMocks() +}) + +/** An env whose plan installs a leaf tool, so the in-image report probe runs. */ +const withTool = () => + manifest({ name: 'env1', spec: { name: 'env1', engine: 'auto', selections: { frameworks: ['foundry'] }, hardening: [], ssh: true } }) + +describe('dcw build', () => { + it('builds the image, persists the manifest, and reports the outcome', async () => { + await saveManifest(manifest({ name: 'env1' })) + + const { result } = await runCommand>(Build, { args: { name: 'env1' }, flags: { force: false }, json: true }) + + expect(result).toMatchObject({ name: 'env1', engine: 'docker', tag: 'dcw/env1:latest', skipped: false, failedTools: [] }) + expect(driver.builds).toHaveLength(1) + expect(driver.builds[0]).toMatchObject({ tag: 'dcw/env1:latest', noCache: false }) + // Image state must survive the command, or the next build starts from scratch. + const saved = await loadManifest('env1') + expect(saved?.image?.tag).toBe('dcw/env1:latest') + expect(saved?.engine).toBe('docker') + }) + + it('honors --platform and --force', async () => { + await saveManifest(manifest({ name: 'env1' })) + await runCommand(Build, { args: { name: 'env1' }, flags: { force: true, platform: 'linux/amd64' }, json: true }) + expect(driver.builds[0]).toMatchObject({ platform: 'linux/amd64', noCache: true }) + }) + + it('passes the flag engine ahead of the spec engine to the resolver', async () => { + await saveManifest(manifest({ name: 'env1', spec: { name: 'env1', engine: 'podman', selections: {}, hardening: [], ssh: true } })) + await runCommand(Build, { args: { name: 'env1' }, flags: { engine: 'lima' }, json: true }) + expect(resolveCalls[0]).toEqual({ requested: 'lima', manifestEngine: 'podman' }) + }) + + it('skips a rebuild when the image is already up to date', async () => { + await saveManifest(manifest({ name: 'env1' })) + await runCommand(Build, { args: { name: 'env1' }, flags: {}, json: true }) + driver.builds.length = 0 + + const { result, logs } = await runCommand<{ skipped: boolean; tag: string }>(Build, { + args: { name: 'env1' }, + flags: {}, + json: false, + }) + + expect(result?.skipped).toBe(true) + expect(driver.builds).toHaveLength(0) + expect(logs.join('\n')).toContain('Image dcw/env1:latest is up to date (skipped).') + }) + + it('logs progress and the built image id in human mode', async () => { + await saveManifest(manifest({ name: 'env1' })) + const { logs, warns } = await runCommand(Build, { args: { name: 'env1' }, flags: {}, json: false }) + expect(logs[0]).toBe("Building 'env1' with Docker…") + // The id is truncated to 19 chars, matching the short-digest convention. + expect(logs[1]).toBe('Built dcw/env1:latest (sha256:fake-dcw/env).') + expect(warns).toEqual([]) + }) + + it('warns about tools that failed to install', async () => { + await saveManifest(withTool()) + driver = new FakeDriver({ name: 'docker', displayName: 'Docker', runOnceResult: { stdout: 'foundry=ok\nforge=fail\n', code: 0 } }) + + const { result, warns } = await runCommand<{ failedTools: string[]; toolsVerified: boolean }>(Build, { + args: { name: 'env1' }, + flags: {}, + json: false, + }) + + expect(result?.failedTools).toEqual(['forge']) + expect(result?.toolsVerified).toBe(true) + expect(warns).toEqual(['tools failed to install: forge']) + }) + + it('warns that install status is unverified when the in-image report is unreadable', async () => { + await saveManifest(withTool()) + driver = new FakeDriver({ name: 'docker', displayName: 'Docker', runOnceResult: { stdout: '', code: 1 } }) + + const { result, warns } = await runCommand<{ failedTools: string[]; toolsVerified: boolean }>(Build, { + args: { name: 'env1' }, + flags: {}, + json: false, + }) + + // An empty failedTools list here means "verification did not run", not "all clean". + expect(result?.failedTools).toEqual([]) + expect(result?.toolsVerified).toBe(false) + expect(warns).toEqual(['could not read the in-image tool report; install status is unverified.']) + }) + + it('stays silent on stdout under --json even when tools fail', async () => { + await saveManifest(withTool()) + driver = new FakeDriver({ name: 'docker', displayName: 'Docker', runOnceResult: { stdout: 'foundry=fail\n', code: 0 } }) + const { logs, warns } = await runCommand(Build, { args: { name: 'env1' }, flags: {}, json: true }) + expect(logs).toEqual([]) + expect(warns).toEqual([]) + }) + + it('resolves the sole environment when no name is given', async () => { + await saveManifest(manifest({ name: 'only' })) + const { result } = await runCommand<{ name: string }>(Build, { args: {}, flags: {}, json: true }) + expect(result?.name).toBe('only') + }) + + it('fails with E_NOT_FOUND for an unknown environment', async () => { + await saveManifest(manifest({ name: 'env1' })) + await expect(runCommand(Build, { args: { name: 'nope' }, flags: {}, json: true })).rejects.toMatchObject({ + code: 'E_NOT_FOUND', + }) + }) +}) diff --git a/packages/core/test/unit/cmd-create.test.ts b/packages/core/test/unit/cmd-create.test.ts new file mode 100644 index 0000000..5ece09d --- /dev/null +++ b/packages/core/test/unit/cmd-create.test.ts @@ -0,0 +1,319 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { caps } from '../../src/engine/drivers/capabilities.js' +import { FakeDriver } from '../engine/fake-driver.js' +import { useTempState } from '../helpers/fixtures.js' +import { runCommand } from '../helpers/command.js' + +let driver: FakeDriver +const mountWizard = vi.fn(async (_opts: unknown) => null as unknown) + +vi.mock('../../src/cli/context.js', async (orig) => { + const actual = await orig() + return { + ...actual, + resolveEngineFor: vi.fn(async () => ({ + driver, + engineName: driver.name, + capabilities: driver.capabilities, + })), + } +}) +vi.mock('../../src/wizard/run.js', () => ({ mountWizard: (opts: unknown) => mountWizard(opts) })) + +const { default: Create } = await import('../../src/commands/create.js') +const { loadManifest, saveManifest } = await import('../../src/state/store.js') + +let state: Awaited> + +/** oclif's parsed defaults for the flags create declares. */ +const base = { ssh: true, build: false, up: false, force: false, yes: false, 'no-input': true, strict: false } + +beforeEach(async () => { + state = await useTempState('dcw-create-') + driver = new FakeDriver({ name: 'docker', displayName: 'Docker' }) + mountWizard.mockReset().mockResolvedValue(null) +}) + +afterEach(async () => { + await state.cleanup() + vi.restoreAllMocks() +}) + +describe('dcw create (non-interactive)', () => { + it('writes a manifest from flags and stops before building', async () => { + const { result } = await runCommand>(Create, { + flags: { ...base, name: 'contracts', 'core-lang': ['rust'], framework: ['foundry'], sec: ['slither'] }, + json: true, + }) + + expect(result).toMatchObject({ name: 'contracts', built: false, started: false, failedTools: [], toolsVerified: true }) + expect(result?.spec.selections).toEqual({ + coreLanguages: ['rust'], + frameworks: ['foundry'], + securityTooling: ['slither'], + }) + expect(driver.builds).toEqual([]) + const saved = await loadManifest('contracts') + expect(saved?.resolved.requiredTools).toContain('foundry') + }) + + it('maps every selection flag onto its spec category', async () => { + const { result } = await runCommand<{ spec: { selections: Record } }>(Create, { + flags: { + ...base, + name: 'wide', + 'core-lang': ['python'], + lang: ['solidity'], + framework: ['hardhat'], + fuzz: ['echidna'], + sec: ['semgrep'], + 'ai-agent': ['claude'], + }, + json: true, + }) + expect(result?.spec.selections).toEqual({ + coreLanguages: ['python'], + languages: ['solidity'], + frameworks: ['hardhat'], + fuzzingAndTesting: ['echidna'], + securityTooling: ['semgrep'], + aiAgents: ['claude'], + }) + }) + + it('derives the name from the working directory when --name is omitted', async () => { + vi.spyOn(process, 'cwd').mockReturnValue('/tmp/My Project') + const { result } = await runCommand<{ name: string }>(Create, { flags: { ...base }, json: true }) + expect(result?.name).toBe('my-project') + }) + + it('applies the default profile when no hardening choice is made at all', async () => { + const { result } = await runCommand<{ spec: { profile?: string; hardening: string[] } }>(Create, { + flags: { ...base, name: 'plain' }, + json: true, + }) + // A bare `create` must not produce an unhardened environment against which + // --strict would then pass vacuously. + expect(result?.spec.profile).toBe('development') + expect(result?.spec.hardening.length).toBeGreaterThan(0) + }) + + it("honors --profile none as the explicit opt-out", async () => { + const { result } = await runCommand<{ spec: { hardening: string[] } }>(Create, { + flags: { ...base, name: 'bare', profile: 'none' }, + json: true, + }) + expect(result?.spec.hardening).toEqual([]) + }) + + it('records the git repository and branch', async () => { + const { result } = await runCommand<{ spec: { gitRepository?: Record } }>(Create, { + flags: { ...base, name: 'cloned', 'git-url': 'https://github.com/o/r.git', 'git-branch': 'main' }, + json: true, + }) + expect(result?.spec.gitRepository).toEqual({ url: 'https://github.com/o/r.git', branch: 'main', enabled: true }) + }) + + it('honors --no-ssh', async () => { + const { result } = await runCommand<{ spec: { ssh: boolean } }>(Create, { + flags: { ...base, name: 'nossh', ssh: false }, + json: true, + }) + expect(result?.spec.ssh).toBe(false) + }) + + it('refuses to clobber an existing environment without --force', async () => { + await runCommand(Create, { flags: { ...base, name: 'dupe' }, json: true }) + await expect(runCommand(Create, { flags: { ...base, name: 'dupe' }, json: true })).rejects.toMatchObject({ + code: 'E_VALIDATION', + message: expect.stringContaining('--force'), + }) + }) + + it('overwrites an existing environment with --force', async () => { + await runCommand(Create, { flags: { ...base, name: 'dupe' }, json: true }) + const { result } = await runCommand<{ spec: { selections: Record } }>(Create, { + flags: { ...base, name: 'dupe', force: true, framework: ['ape'] }, + json: true, + }) + expect(result?.spec.selections).toEqual({ frameworks: ['ape'] }) + }) + + it('rejects an unknown hardening key', async () => { + await expect( + runCommand(Create, { flags: { ...base, name: 'bad', harden: ['not-a-thing'] }, json: true }), + ).rejects.toMatchObject({ code: 'E_VALIDATION' }) + }) +}) + +describe('dcw create --build / --up', () => { + it('--build builds the image but starts nothing', async () => { + const { result } = await runCommand>(Create, { + flags: { ...base, name: 'b', build: true }, + json: true, + }) + expect(result).toMatchObject({ built: true, started: false, container: undefined, hardening: undefined }) + expect(driver.builds).toHaveLength(1) + expect(driver.runs).toEqual([]) + expect((await loadManifest('b'))?.image?.tag).toBe('dcw/b:latest') + }) + + it('--up builds, clears any stale container, starts, and reports hardening', async () => { + const { result } = await runCommand>(Create, { + flags: { ...base, name: 'u', up: true }, + json: true, + }) + expect(result).toMatchObject({ built: true, started: true, container: 'cid-dcw-u' }) + expect(result?.hardening).toMatchObject({ dropped: [], unenforced: [] }) + expect(driver.rms).toEqual([{ id: 'dcw-u', force: true }]) + expect((await loadManifest('u'))?.container?.id).toBe('cid-dcw-u') + }) + + it('ignores a failure from the best-effort stale-container removal', async () => { + vi.spyOn(driver, 'rm').mockRejectedValue(new Error('no such container')) + const { result } = await runCommand<{ started: boolean }>(Create, { + flags: { ...base, name: 'u', up: true }, + json: true, + }) + expect(result?.started).toBe(true) + }) + + it('surfaces dropped hardening in the JSON envelope rather than only to humans', async () => { + driver = new FakeDriver({ + name: 'docker', + capabilities: caps({ networkNone: { support: 'unsupported', note: 'no air-gap here' } }), + }) + const { result } = await runCommand<{ hardening: { dropped: string[] } }>(Create, { + flags: { ...base, name: 'gapped', profile: 'airgapped', up: true }, + json: true, + }) + expect(result?.hardening.dropped).toContain('network-none') + }) + + it('fails a doomed --strict --up run before paying for the build', async () => { + driver = new FakeDriver({ + name: 'docker', + capabilities: caps({ networkNone: { support: 'unsupported', note: 'no air-gap here' } }), + }) + await expect( + runCommand(Create, { flags: { ...base, name: 'gapped', profile: 'airgapped', up: true, strict: true }, json: true }), + ).rejects.toMatchObject({ code: 'E_STRICT_HARDENING' }) + expect(driver.builds).toEqual([]) + // The manifest is still written: the environment exists, it just could not start. + expect(await loadManifest('gapped')).not.toBeNull() + }) + + it('does not apply --strict to a --build-only run, which starts nothing', async () => { + driver = new FakeDriver({ + name: 'docker', + capabilities: caps({ networkNone: { support: 'unsupported', note: 'no air-gap here' } }), + }) + const { result } = await runCommand<{ built: boolean }>(Create, { + flags: { ...base, name: 'gapped', profile: 'airgapped', build: true, strict: true }, + json: true, + }) + expect(result?.built).toBe(true) + }) + + it('reports failed tool installs', async () => { + driver = new FakeDriver({ name: 'docker', runOnceResult: { stdout: 'foundry=fail\n', code: 0 } }) + const { result } = await runCommand<{ failedTools: string[] }>(Create, { + flags: { ...base, name: 't', framework: ['foundry'], build: true }, + json: true, + }) + expect(result?.failedTools).toEqual(['foundry']) + }) +}) + +describe('dcw create (human output)', () => { + it('prints the created line and a next-step hint', async () => { + const { logs } = await runCommand(Create, { flags: { ...base, name: 'h' }, json: false }) + expect(logs).toEqual(["Created environment 'h'.", 'Next: dcw up h']) + }) + + it('prints build and start lines, and no next-step hint, for --up', async () => { + const { logs } = await runCommand(Create, { flags: { ...base, name: 'h', up: true }, json: false }) + expect(logs).toEqual(["Created environment 'h'.", 'Built dcw/h:latest.', 'Started dcw-h.']) + }) + + it('warns about failed tools and about an unreadable tool report', async () => { + driver = new FakeDriver({ name: 'docker', runOnceResult: { stdout: 'foundry=fail\n', code: 0 } }) + const failed = await runCommand(Create, { + flags: { ...base, name: 'h', framework: ['foundry'], build: true }, + json: false, + }) + expect(failed.warns).toEqual(['tools failed to install: foundry']) + + driver = new FakeDriver({ name: 'docker', runOnceResult: { stdout: '', code: 1 } }) + const unverified = await runCommand(Create, { + flags: { ...base, name: 'h2', framework: ['foundry'], build: true }, + json: false, + }) + expect(unverified.warns).toEqual(['could not read the in-image tool report; install status is unverified.']) + }) + + it('warns about dropped and caveated hardening when starting', async () => { + driver = new FakeDriver({ + name: 'docker', + capabilities: caps({ + networkNone: { support: 'unsupported', note: 'no air-gap here' }, + capDrop: { support: 'caveated', note: 'partial only' }, + }), + }) + const { warns } = await runCommand(Create, { + flags: { ...base, name: 'h', profile: 'airgapped', up: true }, + json: false, + }) + expect(warns).toContain('dropped [network-none]: no air-gap here') + expect(warns.some((w) => w.startsWith('note [drop-cap'))).toBe(true) + }) +}) + +describe('dcw create (interactive wizard)', () => { + function withTty(fn: () => Promise): Promise { + const stdin = process.stdin as unknown as { isTTY: boolean | undefined } + const stdout = process.stdout as unknown as { isTTY: boolean | undefined } + const prev = [stdin.isTTY, stdout.isTTY] as const + stdin.isTTY = true + stdout.isTTY = true + return fn().finally(() => { + stdin.isTTY = prev[0] + stdout.isTTY = prev[1] + }) + } + + it('mounts the wizard, seeded with the flag input, and persists what it returns', async () => { + mountWizard.mockResolvedValue({ + name: 'from-wizard', + engine: 'auto', + selections: { frameworks: ['foundry'] }, + hardening: ['drop-caps'], + ssh: true, + }) + + const { result } = await withTty(() => + runCommand<{ name: string }>(Create, { + flags: { ...base, 'no-input': false, name: 'seed', framework: ['ape'] }, + json: false, + }), + ) + + expect(result?.name).toBe('from-wizard') + expect(mountWizard).toHaveBeenCalledWith({ initial: expect.objectContaining({ name: 'seed', frameworks: ['ape'] }) }) + expect((await loadManifest('from-wizard'))?.spec.hardening).toEqual(['drop-caps']) + }) + + it('reports E_CANCELLED when the wizard is dismissed', async () => { + mountWizard.mockResolvedValue(null) + await expect( + withTty(() => runCommand(Create, { flags: { ...base, 'no-input': false }, json: false })), + ).rejects.toMatchObject({ code: 'E_CANCELLED' }) + }) + + it('never mounts the wizard under --json, --yes or --no-input', async () => { + await withTty(() => runCommand(Create, { flags: { ...base, 'no-input': false, name: 'a' }, json: true })) + await withTty(() => runCommand(Create, { flags: { ...base, 'no-input': false, yes: true, name: 'b' }, json: false })) + await withTty(() => runCommand(Create, { flags: { ...base, name: 'c' }, json: false })) + expect(mountWizard).not.toHaveBeenCalled() + }) +}) diff --git a/packages/core/test/unit/cmd-engines.test.ts b/packages/core/test/unit/cmd-engines.test.ts new file mode 100644 index 0000000..1bb451d --- /dev/null +++ b/packages/core/test/unit/cmd-engines.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { EngineStatus } from '../../src/engine/resolver.js' +import { caps, fullCaps } from '../../src/engine/drivers/capabilities.js' +import { runCommand } from '../helpers/command.js' + +const detectHost = vi.fn(async () => ({ os: 'macos', arch: 'arm64', macosMajor: 15 })) +const surveyEngines = vi.fn(async (): Promise => []) + +vi.mock('../../src/engine/host.js', async (orig) => { + const actual = await orig() + return { ...actual, detectHost: (...a: unknown[]) => detectHost(...(a as [])) } +}) +vi.mock('../../src/engine/resolver.js', async (orig) => { + const actual = await orig() + return { ...actual, surveyEngines: (...a: unknown[]) => surveyEngines(...(a as [])) } +}) + +const { default: Engines } = await import('../../src/commands/engines.js') + +function status(over: Partial & Pick): EngineStatus { + return { + displayName: over.name, + platform: { supported: true }, + detect: { available: true, version: '1.0' }, + capabilities: caps(), + recommended: false, + ...over, + } +} + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('dcw engines', () => { + it('reports host info and each engine\'s support, availability and recommendation', async () => { + surveyEngines.mockResolvedValue([ + status({ name: 'orbstack', displayName: 'OrbStack', recommended: true }), + status({ name: 'docker', displayName: 'Docker', detect: { available: false, reason: 'not installed' } }), + status({ + name: 'lima', + displayName: 'Lima', + platform: { supported: false, reason: 'Lima runs on macOS and Linux only.' }, + detect: undefined, + }), + ]) + + const { result } = await runCommand<{ + host: { os: string; arch: string; macosMajor?: number } + engines: Array> + }>(Engines, { json: true }) + + expect(result?.host).toEqual({ os: 'macos', arch: 'arm64', macosMajor: 15 }) + expect(result?.engines.map((e) => e.name)).toEqual(['orbstack', 'docker', 'lima']) + expect(result?.engines[0]).toMatchObject({ available: true, recommended: true, version: '1.0' }) + expect(result?.engines[1]).toMatchObject({ available: false, recommended: false }) + expect(result?.engines[2]).toMatchObject({ + supported: false, + reason: 'Lima runs on macOS and Linux only.', + available: undefined, + }) + }) + + it('splits capability notes into caveats and unsupported drops', async () => { + surveyEngines.mockResolvedValue([ + status({ + name: 'apple-container', + displayName: 'Apple Containers', + capabilities: fullCaps({ + ...caps(), + apparmor: { support: 'unsupported', note: 'no AppArmor in this VM' }, + seccomp: { support: 'caveated', note: 'default profile only' }, + // Note-less entries must still be reported, with a generic label. + sysctl: { support: 'unsupported' }, + dns: { support: 'caveated' }, + }), + }), + ]) + + const { result } = await runCommand<{ engines: Array<{ caveats: string[]; unsupported: string[] }> }>(Engines, { + json: true, + }) + + expect(result?.engines[0]?.caveats).toEqual(['seccomp: default profile only', 'dns: caveat']) + expect(result?.engines[0]?.unsupported).toEqual(['apparmor: no AppArmor in this VM', 'sysctl: unsupported']) + }) + + it('renders a human table with per-engine marks and the dropped-capability line', async () => { + surveyEngines.mockResolvedValue([ + status({ name: 'orbstack', displayName: 'OrbStack', recommended: true }), + status({ name: 'podman', displayName: 'Podman', detect: { available: true, version: '5.2' } }), + status({ name: 'lima', displayName: 'Lima (nerdctl)', detect: { available: true } }), + status({ name: 'docker', displayName: 'Docker', detect: { available: false } }), + status({ + name: 'lima', + displayName: 'Lima', + platform: { supported: false }, + detect: undefined, + }), + status({ + name: 'apple-container', + displayName: 'Apple Containers', + capabilities: fullCaps({ ...caps(), apparmor: { support: 'unsupported', note: 'n/a' } }), + }), + ]) + + const { logs } = await runCommand(Engines, { json: false }) + const text = logs.join('\n') + + expect(text).toContain('Host: macOS 15 (arm64)') + expect(text).toMatch(/★ {2}OrbStack {13}available — 1\.0 {2}\[recommended]/) + expect(text).toMatch(/✓ {2}Podman {15}available — 5\.2/) + // An available engine that reported no version string still reads cleanly. + expect(text).toContain('✓ Lima (nerdctl) available\n') + expect(text).toMatch(/· {2}Docker {15}not detected/) + // Unsupported engines report the reason, falling back to 'n/a' when absent. + expect(text).toMatch(/✗ Lima {17}unsupported \(n\/a\)/) + // Only the capability KEY is shown on the drops line, not the whole note. + expect(text).toContain(' drops: apparmor') + }) +}) diff --git a/packages/core/test/unit/cmd-ls.test.ts b/packages/core/test/unit/cmd-ls.test.ts new file mode 100644 index 0000000..aaf8034 --- /dev/null +++ b/packages/core/test/unit/cmd-ls.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ContainerInfo } from '../../src/engine/types.js' +import { NoEngineError } from '../../src/errors.js' +import { manifest, useTempState } from '../helpers/fixtures.js' +import { runCommand } from '../helpers/command.js' + +/** Per-engine ps() results; an entry that throws models an unreachable engine. */ +const psByEngine = new Map() +const seenRequests: Array<{ requested?: string; manifestEngine?: string }> = [] + +vi.mock('../../src/cli/context.js', async (orig) => { + const actual = await orig() + return { + ...actual, + resolveEngineFor: vi.fn(async (opts: { requested?: string; manifestEngine?: string }) => { + seenRequests.push(opts) + const key = opts.requested ?? opts.manifestEngine + const entry = psByEngine.get(key) + if (entry instanceof Error) throw entry + return { + driver: { name: key ?? 'docker', ps: async () => entry ?? [] }, + } + }), + } +}) + +const { default: Ls } = await import('../../src/commands/ls.js') +const { saveManifest } = await import('../../src/state/store.js') + +let state: Awaited> + +beforeEach(async () => { + state = await useTempState('dcw-ls-') + psByEngine.clear() + seenRequests.length = 0 +}) + +afterEach(async () => { + await state.cleanup() + vi.clearAllMocks() +}) + +function container(name: string, status: string): ContainerInfo { + return { id: 'cid', name, image: 'img', status, labels: {} } +} + +const startedContainer = { id: 'cid', name: 'dcw-x', status: 'running' as const, startedAt: '2026-06-11T00:00:00.000Z' } + +describe('dcw ls', () => { + it('returns an empty list and a hint when no environments exist', async () => { + const { result, logs } = await runCommand<{ environments: unknown[] }>(Ls, { flags: {} }) + expect(result?.environments).toEqual([]) + expect(logs.join('\n')).toContain('No environments yet') + }) + + it('reconciles each environment against ITS OWN engine, not one auto-detected engine', async () => { + await saveManifest(manifest({ name: 'a', engine: 'docker', container: { ...startedContainer, name: 'dcw-a' } })) + await saveManifest(manifest({ name: 'b', engine: 'podman', container: { ...startedContainer, name: 'dcw-b' } })) + // Each container is only visible to its own engine. + psByEngine.set('docker', [container('dcw-a', 'Up 3 minutes')]) + psByEngine.set('podman', [container('dcw-b', 'Exited (0) 1 hour ago')]) + + const { result } = await runCommand<{ environments: Array<{ name: string; status: string }> }>(Ls, { flags: {} }) + + expect(result?.environments).toEqual([ + expect.objectContaining({ name: 'a', status: 'running' }), + expect.objectContaining({ name: 'b', status: 'stopped' }), + ]) + expect(seenRequests.map((r) => r.manifestEngine).sort()).toEqual(['docker', 'podman']) + }) + + it("reports 'unknown' when the environment's engine cannot be reached", async () => { + await saveManifest(manifest({ name: 'ghost', engine: 'docker', container: { ...startedContainer, name: 'dcw-ghost' } })) + psByEngine.set('docker', new NoEngineError('no engine')) + + const { result } = await runCommand<{ environments: Array<{ status: string }> }>(Ls, { flags: {} }) + // 'absent' would assert the container is gone; we simply cannot tell. + expect(result?.environments[0]?.status).toBe('unknown') + }) + + it("reports 'absent' when a reachable engine no longer has the container", async () => { + await saveManifest(manifest({ name: 'gone', engine: 'docker', container: { ...startedContainer, name: 'dcw-gone' } })) + psByEngine.set('docker', []) + + const { result } = await runCommand<{ environments: Array<{ status: string }> }>(Ls, { flags: {} }) + expect(result?.environments[0]?.status).toBe('absent') + }) + + it("reports 'never-started' for an environment that has no container record", async () => { + await saveManifest(manifest({ name: 'fresh' })) + const { result } = await runCommand<{ environments: Array<{ status: string }> }>(Ls, { flags: {} }) + expect(result?.environments[0]?.status).toBe('never-started') + }) + + it('probes only the requested engine when --engine is given', async () => { + await saveManifest(manifest({ name: 'a', engine: 'docker', container: { ...startedContainer, name: 'dcw-a' } })) + await saveManifest(manifest({ name: 'b', engine: 'podman', container: { ...startedContainer, name: 'dcw-b' } })) + psByEngine.set('lima', [container('dcw-a', 'Up 1 second')]) + + const { result } = await runCommand<{ environments: Array<{ name: string; status: string }> }>(Ls, { + flags: { engine: 'lima' }, + }) + + expect(seenRequests).toEqual([{ requested: 'lima', manifestEngine: 'lima' }]) + expect(result?.environments).toEqual([ + expect.objectContaining({ name: 'a', status: 'running' }), + // 'b' lives on podman, but --engine pins the lookup to lima, where it is absent. + expect.objectContaining({ name: 'b', status: 'absent' }), + ]) + }) + + it('summarizes build state, tool count and hardening count', async () => { + await saveManifest( + manifest({ + name: 'rich', + engine: 'docker', + image: { tag: 'dcw/rich:latest', containerfileHash: 'h', imageId: 'sha256:1' }, + resolved: { requiredTools: ['rust', 'foundry'], hardeningKeys: ['drop-caps', 'read-only-root'] }, + }), + ) + psByEngine.set('docker', []) + + const { result } = await runCommand<{ environments: Array> }>(Ls, { flags: {} }) + expect(result?.environments[0]).toMatchObject({ built: true, tools: 2, hardening: 2, engine: 'docker' }) + }) + + it("groups environments with no engine preference at all under the auto target", async () => { + await saveManifest(manifest({ name: 'undecided' })) + psByEngine.set('auto', []) + const { result } = await runCommand<{ environments: Array<{ engine: string | null }> }>(Ls, { flags: {} }) + expect(seenRequests).toEqual([{ requested: undefined, manifestEngine: 'auto' }]) + expect(result?.environments[0]?.engine).toBeNull() + }) + + it('renders an aligned human table when --json is absent', async () => { + await saveManifest(manifest({ name: 'alpha', engine: 'docker' })) + psByEngine.set('docker', []) + + const { logs } = await runCommand(Ls, { flags: {}, json: false }) + expect(logs[0]).toBe('NAME ENGINE BUILT STATUS') + expect(logs[1]).toBe('alpha docker no never-started') + }) + + it('prints nothing but the payload under --json', async () => { + await saveManifest(manifest({ name: 'alpha', engine: 'docker' })) + psByEngine.set('docker', []) + const { logs } = await runCommand(Ls, { flags: {}, json: true }) + expect(logs).toEqual([]) + }) + + it('falls back to the spec engine when the manifest has no resolved engine yet', async () => { + await saveManifest(manifest({ name: 'specced', spec: { name: 'specced', engine: 'podman', selections: {}, hardening: [], ssh: true } })) + psByEngine.set('podman', []) + const { result } = await runCommand<{ environments: Array<{ engine: string | null }> }>(Ls, { flags: {} }) + expect(seenRequests[0]?.manifestEngine).toBe('podman') + // `engine` in the row is the RESOLVED engine, still null until a build/up runs. + expect(result?.environments[0]?.engine).toBeNull() + }) +}) diff --git a/packages/core/test/unit/cmd-rm-stop.test.ts b/packages/core/test/unit/cmd-rm-stop.test.ts new file mode 100644 index 0000000..9072dbd --- /dev/null +++ b/packages/core/test/unit/cmd-rm-stop.test.ts @@ -0,0 +1,254 @@ +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { NoEngineError } from '../../src/errors.js' +import { FakeDriver } from '../engine/fake-driver.js' +import { manifest, useTempState } from '../helpers/fixtures.js' +import { runCommand } from '../helpers/command.js' + +let driver: FakeDriver | undefined +let resolveError: Error | undefined + +vi.mock('../../src/engine/resolver.js', async (orig) => { + const actual = await orig() + return { + ...actual, + resolveEngine: async () => { + if (resolveError) throw resolveError + return { driver, detect: { available: true }, platform: { supported: true } } + }, + } +}) +vi.mock('../../src/engine/host.js', async (orig) => { + const actual = await orig() + return { ...actual, detectHost: async () => ({ os: 'linux', arch: 'x64' }) } +}) + +const { default: Rm } = await import('../../src/commands/rm.js') +const { default: Stop } = await import('../../src/commands/stop.js') +const { loadManifest, saveManifest } = await import('../../src/state/store.js') +const { hostAlias } = await import('../../src/core/ssh/ssh-config.js') + +let state: Awaited> +let prevHome: string | undefined + +const started = (name = 'env1') => + manifest({ + name, + engine: 'docker', + container: { id: `cid-${name}`, name: `dcw-${name}`, status: 'running' } as never, + }) + +const present = [{ id: 'cid-env1', name: 'dcw-env1', image: 'img', status: 'Up 2m', labels: {} }] + +beforeEach(async () => { + state = await useTempState('dcw-rmstop-') + prevHome = process.env.HOME + process.env.HOME = path.join(state.dir, 'home') + await fs.mkdir(path.join(process.env.HOME, '.ssh'), { recursive: true }) + driver = new FakeDriver({ name: 'docker' }) + resolveError = undefined +}) + +afterEach(async () => { + if (prevHome === undefined) delete process.env.HOME + else process.env.HOME = prevHome + await state.cleanup() + vi.clearAllMocks() +}) + +describe('dcw rm', () => { + it('refuses to purge without confirmation', async () => { + await saveManifest(started()) + await expect(runCommand(Rm, { args: { name: 'env1' }, flags: { purge: true }, json: true })).rejects.toMatchObject({ + code: 'E_CONFIRM', + exitCode: 2, + }) + expect(await loadManifest('env1')).not.toBeNull() + }) + + it('removes the container by label presence and clears it from the manifest', async () => { + await saveManifest(started()) + vi.spyOn(driver!, 'ps').mockResolvedValue(present) + + const { result, logs } = await runCommand<{ removedContainer: boolean }>(Rm, { + args: { name: 'env1' }, + flags: { purge: false }, + json: false, + }) + + expect(result?.removedContainer).toBe(true) + expect(driver!.rms).toEqual([{ id: 'cid-env1', force: true }]) + expect(logs).toEqual(["Removed container for 'env1'. Run `dcw up env1` to restart."]) + expect((await loadManifest('env1'))?.container).toBeNull() + }) + + it('falls back to the derived container name when no id was recorded', async () => { + await saveManifest(manifest({ name: 'env1', engine: 'docker', container: { name: 'dcw-env1' } as never })) + vi.spyOn(driver!, 'ps').mockResolvedValue(present) + await runCommand(Rm, { args: { name: 'env1' }, flags: {}, json: true }) + expect(driver!.rms).toEqual([{ id: 'dcw-env1', force: true }]) + }) + + it('reports a benign no-op when the container is already gone', async () => { + await saveManifest(started()) + const { result, logs } = await runCommand<{ removedContainer: boolean }>(Rm, { + args: { name: 'env1' }, + flags: {}, + json: false, + }) + expect(result?.removedContainer).toBe(false) + expect(logs).toEqual(["No container to remove for 'env1'."]) + }) + + it('purges the record, the state dir and the managed ssh config block', async () => { + await saveManifest(started()) + const sshConfig = path.join(os.homedir(), '.ssh', 'config') + await fs.writeFile(sshConfig, `Host other\n\n# >>> dcw env1 >>>\nHost ${hostAlias('env1')}\n# <<< dcw env1 <<<\n`) + + const { result, logs } = await runCommand<{ purged: boolean }>(Rm, { + args: { name: 'env1' }, + flags: { purge: true, yes: true }, + json: false, + }) + + expect(result?.purged).toBe(true) + expect(await loadManifest('env1')).toBeNull() + expect(await fs.readFile(sshConfig, 'utf8')).not.toContain('dcw-env1') + expect(logs).toEqual(["Purged environment 'env1'."]) + }) + + it('purges even when the ssh config cannot be rewritten', async () => { + await saveManifest(started()) + // No ~/.ssh at all: removeSshConfig must not take the purge down with it. + await fs.rm(path.join(os.homedir(), '.ssh'), { recursive: true, force: true }) + const { result } = await runCommand<{ purged: boolean }>(Rm, { + args: { name: 'env1' }, + flags: { purge: true, yes: true }, + json: true, + }) + expect(result?.purged).toBe(true) + }) + + it('purges local state when the engine is gone, instead of stranding the environment', async () => { + await saveManifest(started()) + resolveError = new NoEngineError('No supported container engine is available.') + + const { result, warns } = await runCommand<{ purged: boolean; removedContainer: boolean }>(Rm, { + args: { name: 'env1' }, + flags: { purge: true, yes: true }, + json: false, + }) + + expect(result).toMatchObject({ purged: true, removedContainer: false }) + expect(warns[0]).toContain('Purging local state anyway') + expect(await loadManifest('env1')).toBeNull() + }) + + it('still refuses a non-purge rm when the engine is unavailable', async () => { + await saveManifest(started()) + resolveError = new NoEngineError('No supported container engine is available.') + await expect(runCommand(Rm, { args: { name: 'env1' }, flags: {}, json: true })).rejects.toMatchObject({ + code: 'E_NO_ENGINE', + }) + }) + + it('reports a non-Error engine failure without crashing on it', async () => { + await saveManifest(started()) + resolveError = 'daemon exploded' as unknown as Error + const { warns } = await runCommand(Rm, { args: { name: 'env1' }, flags: { purge: true, yes: true }, json: false }) + expect(warns[0]).toContain('daemon exploded') + }) + + it('purges a record that no longer validates, and warns about it', async () => { + const { manifestPath } = await import('../../src/state/paths.js') + await fs.mkdir(path.dirname(manifestPath('env1')), { recursive: true }) + await fs.writeFile(manifestPath('env1'), JSON.stringify({ schemaVersion: 1, name: 'env1', nope: true })) + vi.spyOn(driver!, 'ps').mockResolvedValue(present) + + const { result, warns } = await runCommand<{ purged: boolean; removedContainer: boolean }>(Rm, { + args: { name: 'env1' }, + flags: { purge: true, yes: true }, + json: false, + }) + + // Without the fallback the record is stuck: hidden from `ls`, unremovable. + expect(result).toMatchObject({ purged: true, removedContainer: true }) + expect(warns[0]).toContain('Purging the invalid record anyway.') + await expect(fs.access(manifestPath('env1'))).rejects.toThrow() + }) + + it('skips container removal for an environment that never had one', async () => { + await saveManifest(manifest({ name: 'env1' })) + const psSpy = vi.spyOn(driver!, 'ps') + const { result } = await runCommand<{ removedContainer: boolean }>(Rm, { + args: { name: 'env1' }, + flags: {}, + json: true, + }) + expect(result?.removedContainer).toBe(false) + expect(psSpy).not.toHaveBeenCalled() + }) + + it('stays silent on stdout under --json', async () => { + await saveManifest(started()) + const { logs } = await runCommand(Rm, { args: { name: 'env1' }, flags: { purge: true, yes: true }, json: true }) + expect(logs).toEqual([]) + }) +}) + +describe('dcw stop', () => { + it('stops a running container and records the stopped status', async () => { + await saveManifest(started()) + vi.spyOn(driver!, 'ps').mockResolvedValue(present) + + const { result, logs } = await runCommand<{ stopped: boolean }>(Stop, { + args: { name: 'env1' }, + flags: {}, + json: false, + }) + + expect(result?.stopped).toBe(true) + expect(driver!.stops).toEqual(['cid-env1']) + expect(logs).toEqual(['Stopped cid-env1.']) + expect((await loadManifest('env1'))?.container?.status).toBe('stopped') + }) + + it('falls back to the derived container name when no id was recorded', async () => { + await saveManifest(manifest({ name: 'env1', engine: 'docker', container: { name: 'dcw-env1' } as never })) + vi.spyOn(driver!, 'ps').mockResolvedValue(present) + await runCommand(Stop, { args: { name: 'env1' }, flags: {}, json: true }) + expect(driver!.stops).toEqual(['dcw-env1']) + }) + + it('is a benign no-op when no container was ever recorded', async () => { + await saveManifest(manifest({ name: 'env1' })) + const { result, logs } = await runCommand<{ stopped: boolean }>(Stop, { + args: { name: 'env1' }, + flags: {}, + json: false, + }) + expect(result?.stopped).toBe(false) + expect(logs).toEqual(["Nothing to stop for 'env1'."]) + }) + + it('is a benign no-op when the recorded container is already gone', async () => { + await saveManifest(started()) + const { result, logs } = await runCommand<{ stopped: boolean }>(Stop, { + args: { name: 'env1' }, + flags: {}, + json: false, + }) + expect(result?.stopped).toBe(false) + expect(logs).toEqual(["Nothing to stop for 'env1' (no container)."]) + expect(driver!.stops).toEqual([]) + }) + + it('stays silent on stdout under --json', async () => { + await saveManifest(started()) + vi.spyOn(driver!, 'ps').mockResolvedValue(present) + const { logs } = await runCommand(Stop, { args: { name: 'env1' }, flags: {}, json: true }) + expect(logs).toEqual([]) + }) +}) diff --git a/packages/core/test/unit/cmd-schema.test.ts b/packages/core/test/unit/cmd-schema.test.ts new file mode 100644 index 0000000..a57bc5d --- /dev/null +++ b/packages/core/test/unit/cmd-schema.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import Schema from '../../src/commands/schema.js' +import { runCommand } from '../helpers/command.js' +import type { SchemaDoc } from '../../src/spec/schema-doc.js' + +describe('dcw schema', () => { + it('returns the machine-readable schema doc stamped with the CLI version', async () => { + const { result, logs } = await runCommand(Schema, { json: true, version: '9.9.9' }) + expect(result?.version).toBe('9.9.9') + expect(result?.catalog.length).toBeGreaterThan(0) + expect(result?.engines).toContain('docker') + // Under --json oclif serializes the return value; the command must not also + // print it, or the stream would carry the document twice. + expect(logs).toHaveLength(0) + }) + + it('prints the same document as pretty JSON when --json is absent', async () => { + const { result, logs } = await runCommand(Schema, { json: false, version: '9.9.9' }) + expect(logs).toHaveLength(1) + expect(JSON.parse(logs[0]!)).toEqual(result) + expect(logs[0]).toContain('\n ') + }) +}) diff --git a/packages/core/test/unit/cmd-skill.test.ts b/packages/core/test/unit/cmd-skill.test.ts new file mode 100644 index 0000000..4ec868d --- /dev/null +++ b/packages/core/test/unit/cmd-skill.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest' +import Skill from '../../src/commands/skill.js' +import { readSkill } from '../../src/skill.js' +import { runCommand } from '../helpers/command.js' + +describe('dcw skill', () => { + it('writes the packaged SKILL.md verbatim to stdout', async () => { + const written: string[] = [] + const spy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => { + written.push(String(chunk)) + return true + }) + try { + await runCommand(Skill, {}) + } finally { + spy.mockRestore() + } + expect(written.join('')).toBe(readSkill()) + // Raw markdown pass-through: no JSON envelope, no trailing framework noise. + expect(written.join('')).toContain('dcw') + }) +}) diff --git a/packages/core/test/unit/cmd-streaming.test.ts b/packages/core/test/unit/cmd-streaming.test.ts new file mode 100644 index 0000000..834200d --- /dev/null +++ b/packages/core/test/unit/cmd-streaming.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { FakeDriver } from '../engine/fake-driver.js' +import { manifest, useTempState } from '../helpers/fixtures.js' +import { runCommand } from '../helpers/command.js' + +let driver: FakeDriver + +// Mock the engine RESOLVER (not cli/context) so the real context helpers — +// resolveEnvName, requireManifest, execInto — run for these commands. +vi.mock('../../src/engine/resolver.js', async (orig) => { + const actual = await orig() + return { + ...actual, + resolveEngine: async () => ({ driver, detect: { available: true }, platform: { supported: true } }), + } +}) + +vi.mock('../../src/engine/host.js', async (orig) => { + const actual = await orig() + // Skip the real `sw_vers` probe: it spawns a subprocess on every resolve. + return { ...actual, detectHost: async () => ({ os: 'linux', arch: 'x64' }) } +}) + +const { default: Shell } = await import('../../src/commands/shell.js') +const { default: Exec } = await import('../../src/commands/exec.js') +const { default: Logs } = await import('../../src/commands/logs.js') +const { default: SshProxy } = await import('../../src/commands/ssh-proxy.js') +const { SSHD_CONFIG_PATH } = await import('../../src/containerfile/base.js') +const { saveManifest } = await import('../../src/state/store.js') + +let state: Awaited> + +const started = (name: string, over: Record = {}) => + manifest({ + name, + engine: 'docker', + container: { id: `cid-${name}`, name: `dcw-${name}`, status: 'running', ...over } as never, + }) + +beforeEach(async () => { + state = await useTempState('dcw-stream-') + driver = new FakeDriver({ name: 'docker' }) +}) + +afterEach(async () => { + await state.cleanup() + vi.restoreAllMocks() +}) + +describe('dcw shell', () => { + it('execs zsh into the container and exits with the container exit code', async () => { + await saveManifest(started('env1')) + driver = new FakeDriver({ name: 'docker', execResult: 42 }) + + const { exitCode } = await runCommand(Shell, { args: { name: 'env1' }, flags: {} }) + + expect(exitCode).toBe(42) + expect(driver.execs[0]).toMatchObject({ container: 'cid-env1', cmd: ['zsh'] }) + }) + + it('refuses under --strict when the running container lost hardening', async () => { + await saveManifest(started('env1', { droppedHardening: ['network-none'] })) + await expect(runCommand(Shell, { args: { name: 'env1' }, flags: { strict: true } })).rejects.toMatchObject({ + code: 'E_STRICT_HARDENING', + }) + }) +}) + +describe('dcw exec', () => { + it('treats the first token as the environment when it names one', async () => { + await saveManifest(started('env1')) + const { exitCode } = await runCommand(Exec, { argv: ['env1', 'forge', '--version'], flags: {} }) + expect(exitCode).toBe(0) + expect(driver.execs[0]).toMatchObject({ container: 'cid-env1', cmd: ['forge', '--version'] }) + }) + + it('treats the first token as part of the command when it names no environment', async () => { + await saveManifest(started('only')) + // The documented `dcw exec -- forge --version` form must not mistake 'forge' + // for an environment name. + await runCommand(Exec, { argv: ['forge', '--version'], flags: {} }) + expect(driver.execs[0]).toMatchObject({ container: 'cid-only', cmd: ['forge', '--version'] }) + }) + + it('never treats a leading flag-like token as an environment name', async () => { + await saveManifest(started('only')) + await runCommand(Exec, { argv: ['--version'], flags: {} }) + expect(driver.execs[0]?.cmd).toEqual(['--version']) + }) + + it('falls back to an interactive zsh when no command is given at all', async () => { + await saveManifest(started('only')) + await runCommand(Exec, { argv: [], flags: {} }) + expect(driver.execs[0]?.cmd).toEqual(['zsh']) + }) + + it('propagates the container exit code', async () => { + await saveManifest(started('only')) + driver = new FakeDriver({ name: 'docker', execResult: 7 }) + const { exitCode } = await runCommand(Exec, { argv: ['false'], flags: {} }) + expect(exitCode).toBe(7) + }) +}) + +describe('dcw logs', () => { + it('streams logs from the recorded container id', async () => { + await saveManifest(started('env1')) + const { exitCode } = await runCommand(Logs, { args: { name: 'env1' }, flags: { follow: false } }) + expect(exitCode).toBe(0) + expect(driver.logsCalls[0]).toEqual({ id: 'cid-env1', opts: { follow: false, tail: undefined } }) + }) + + it('forwards --follow and --tail', async () => { + await saveManifest(started('env1')) + await runCommand(Logs, { args: { name: 'env1' }, flags: { follow: true, tail: 50 } }) + expect(driver.logsCalls[0]?.opts).toEqual({ follow: true, tail: 50 }) + }) + + it('falls back to the derived container name when no id was recorded', async () => { + await saveManifest(started('env1', { id: undefined })) + await runCommand(Logs, { args: { name: 'env1' }, flags: {} }) + expect(driver.logsCalls[0]?.id).toBe('dcw-env1') + }) + + it('propagates the exit code of the log stream', async () => { + await saveManifest(started('env1')) + driver = new FakeDriver({ name: 'docker', logsResult: 3 }) + const { exitCode } = await runCommand(Logs, { args: { name: 'env1' }, flags: {} }) + expect(exitCode).toBe(3) + }) + + it('raises E_NOT_FOUND when the environment has no container', async () => { + await saveManifest(manifest({ name: 'env1' })) + await expect(runCommand(Logs, { args: { name: 'env1' }, flags: {} })).rejects.toMatchObject({ + code: 'E_NOT_FOUND', + }) + }) + + it("falls back to the spec's engine when none was resolved yet", async () => { + await saveManifest( + manifest({ + name: 'env1', + engine: null, + spec: { name: 'env1', engine: 'podman', selections: {}, hardening: [], ssh: true }, + container: { id: 'cid-env1', name: 'dcw-env1', status: 'running' } as never, + }), + ) + const { exitCode } = await runCommand(Logs, { args: { name: 'env1' }, flags: {} }) + expect(exitCode).toBe(0) + }) +}) + +describe('dcw ssh-proxy', () => { + it('execs a one-shot inetd sshd as the vscode user over the engine exec channel', async () => { + await saveManifest(started('env1')) + const { exitCode } = await runCommand(SshProxy, { args: { name: 'env1' }, flags: {} }) + + expect(exitCode).toBe(0) + expect(driver.execs[0]).toEqual({ + container: 'cid-env1', + cmd: ['/usr/sbin/sshd', '-i', '-f', SSHD_CONFIG_PATH], + interactive: true, + // No TTY: stdout carries the raw SSH protocol stream, nothing else. + tty: false, + user: 'vscode', + }) + }) + + it('falls back to the container name when no id was recorded', async () => { + await saveManifest(started('env1', { id: undefined })) + await runCommand(SshProxy, { args: { name: 'env1' }, flags: {} }) + expect(driver.execs[0]?.container).toBe('dcw-env1') + }) + + it('propagates the exit code of the proxied session', async () => { + await saveManifest(started('env1')) + driver = new FakeDriver({ name: 'docker', execResult: 255 }) + const { exitCode } = await runCommand(SshProxy, { args: { name: 'env1' }, flags: {} }) + expect(exitCode).toBe(255) + }) + + it('raises E_NOT_FOUND with an `up` hint when the environment has no container', async () => { + await saveManifest(manifest({ name: 'env1' })) + await expect(runCommand(SshProxy, { args: { name: 'env1' }, flags: {} })).rejects.toMatchObject({ + code: 'E_NOT_FOUND', + message: expect.stringContaining('dcw up env1'), + }) + }) + + it("falls back to the spec's engine when none was resolved yet", async () => { + await saveManifest( + manifest({ + name: 'env1', + engine: null, + spec: { name: 'env1', engine: 'podman', selections: {}, hardening: [], ssh: true }, + container: { id: 'cid-env1', name: 'dcw-env1', status: 'running' } as never, + }), + ) + const { exitCode } = await runCommand(SshProxy, { args: { name: 'env1' }, flags: {} }) + expect(exitCode).toBe(0) + }) +}) diff --git a/packages/core/test/unit/cmd-up.test.ts b/packages/core/test/unit/cmd-up.test.ts new file mode 100644 index 0000000..4a6b99d --- /dev/null +++ b/packages/core/test/unit/cmd-up.test.ts @@ -0,0 +1,211 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { caps } from '../../src/engine/drivers/capabilities.js' +import { FakeDriver } from '../engine/fake-driver.js' +import { manifest, useTempState } from '../helpers/fixtures.js' +import { runCommand } from '../helpers/command.js' + +let driver: FakeDriver + +vi.mock('../../src/cli/context.js', async (orig) => { + const actual = await orig() + return { + ...actual, + resolveEngineFor: vi.fn(async () => ({ + driver, + engineName: driver.name, + capabilities: driver.capabilities, + })), + } +}) + +const { default: Up } = await import('../../src/commands/up.js') +const { saveManifest, loadManifest } = await import('../../src/state/store.js') + +let state: Awaited> + +beforeEach(async () => { + state = await useTempState('dcw-up-') + driver = new FakeDriver({ name: 'docker', displayName: 'Docker' }) +}) + +afterEach(async () => { + await state.cleanup() + vi.clearAllMocks() +}) + +const hardened = (keys: string[]) => + manifest({ name: 'env1', spec: { name: 'env1', engine: 'auto', selections: {}, hardening: keys, ssh: true } }) + +describe('dcw up', () => { + it('builds then starts the container and returns the full envelope', async () => { + await saveManifest(manifest({ name: 'env1' })) + + const { result } = await runCommand>(Up, { + args: { name: 'env1' }, + flags: { workspace: '/host/ws' }, + json: true, + }) + + expect(driver.builds).toHaveLength(1) + expect(driver.runs).toHaveLength(1) + expect(result).toMatchObject({ name: 'env1', engine: 'docker', containerId: 'cid-dcw-env1', failedTools: [] }) + expect(result?.appliedFlags).toContain('/host/ws:/workspace') + expect(result?.hardening).toEqual({ appliedFlags: result?.appliedFlags, warnings: [], dropped: [], unenforced: [] }) + const saved = await loadManifest('env1') + expect(saved?.container).toMatchObject({ id: 'cid-dcw-env1', name: 'dcw-env1', status: 'running' }) + }) + + it('mounts the current directory when --workspace is omitted', async () => { + await saveManifest(manifest({ name: 'env1' })) + const { result } = await runCommand<{ appliedFlags: string[] }>(Up, { args: { name: 'env1' }, flags: {}, json: true }) + expect(result?.appliedFlags).toContain(`${process.cwd()}:/workspace`) + }) + + it('removes any stale container of the same name before starting a fresh one', async () => { + await saveManifest(manifest({ name: 'env1' })) + await runCommand(Up, { args: { name: 'env1' }, flags: {}, json: true }) + expect(driver.rms).toEqual([{ id: 'dcw-env1', force: true }]) + }) + + it('ignores a failure from that best-effort cleanup rm', async () => { + await saveManifest(manifest({ name: 'env1' })) + vi.spyOn(driver, 'rm').mockRejectedValue(new Error('no such container')) + const { result } = await runCommand<{ containerId: string }>(Up, { args: { name: 'env1' }, flags: {}, json: true }) + expect(result?.containerId).toBe('cid-dcw-env1') + }) + + it('persists image state even when the run step then fails', async () => { + await saveManifest(manifest({ name: 'env1' })) + vi.spyOn(driver, 'run').mockRejectedValue(new Error('port already allocated')) + + await expect(runCommand(Up, { args: { name: 'env1' }, flags: {}, json: true })).rejects.toThrow( + 'port already allocated', + ) + + // Without the mid-flight save, the next `up` would rebuild from scratch. + const saved = await loadManifest('env1') + expect(saved?.image?.imageId).toBe('sha256:fake-dcw/env1:latest') + }) + + it('rejects --strict BEFORE building when the engine cannot honor the hardening', async () => { + await saveManifest(hardened(['apparmor'])) + driver = new FakeDriver({ + name: 'docker', + capabilities: caps({ apparmor: { support: 'unsupported', note: 'no LSM in this VM' } }), + }) + + await expect( + runCommand(Up, { args: { name: 'env1' }, flags: { strict: true }, json: true }), + ).rejects.toMatchObject({ code: 'E_STRICT_HARDENING' }) + + // The point of the early check: a strict run that cannot succeed must not + // spend minutes building an image first. + expect(driver.builds).toEqual([]) + }) + + it('rejects --strict for hardening that is emitted but unenforced', async () => { + await saveManifest(hardened(['apparmor'])) + driver = new FakeDriver({ + name: 'docker', + capabilities: caps({ apparmor: { support: 'caveated', enforced: false, note: 'accepted but inert' } }), + }) + await expect( + runCommand(Up, { args: { name: 'env1' }, flags: { strict: true }, json: true }), + ).rejects.toMatchObject({ code: 'E_STRICT_HARDENING' }) + }) + + it('reports dropped hardening in the JSON envelope instead of failing without --strict', async () => { + await saveManifest(hardened(['apparmor'])) + driver = new FakeDriver({ + name: 'docker', + capabilities: caps({ apparmor: { support: 'unsupported', note: 'no LSM in this VM' } }), + }) + + const { result } = await runCommand>(Up, { args: { name: 'env1' }, flags: {}, json: true }) + + expect(result?.dropped).toEqual(['apparmor']) + expect(result?.warnings).toEqual([{ level: 'dropped', effect: 'apparmor', message: 'no LSM in this VM' }]) + expect(result?.hardening.dropped).toEqual(['apparmor']) + const saved = await loadManifest('env1') + expect(saved?.container?.droppedHardening).toEqual(['apparmor']) + }) + + it('forces a rebuild with --rebuild', async () => { + await saveManifest(manifest({ name: 'env1' })) + await runCommand(Up, { args: { name: 'env1' }, flags: {}, json: true }) + driver.builds.length = 0 + await runCommand(Up, { args: { name: 'env1' }, flags: { rebuild: true }, json: true }) + expect(driver.builds).toHaveLength(1) + expect(driver.builds[0]?.noCache).toBe(true) + }) + + it('logs the build, the start line and the shell hint in human mode', async () => { + await saveManifest(manifest({ name: 'env1' })) + const { logs, warns } = await runCommand(Up, { args: { name: 'env1' }, flags: {}, json: false }) + expect(logs[0]).toBe('Built dcw/env1:latest.') + expect(logs[1]).toBe('Started dcw-env1 on Docker (cid-dcw-env1).') + expect(logs[2]).toBe('\nShell in with: dcw shell env1') + expect(warns).toEqual([]) + }) + + it('skips the build line on a second, up-to-date run', async () => { + await saveManifest(manifest({ name: 'env1' })) + await runCommand(Up, { args: { name: 'env1' }, flags: {}, json: false }) + const { logs } = await runCommand(Up, { args: { name: 'env1' }, flags: {}, json: false }) + expect(logs.some((l) => l.startsWith('Built '))).toBe(false) + expect(logs[0]).toBe('Started dcw-env1 on Docker (cid-dcw-env1).') + }) + + it('warns in human mode about failed tools and about dropped hardening', async () => { + await saveManifest( + manifest({ + name: 'env1', + spec: { name: 'env1', engine: 'auto', selections: { frameworks: ['foundry'] }, hardening: ['apparmor'], ssh: true }, + }), + ) + driver = new FakeDriver({ + name: 'docker', + displayName: 'Docker', + capabilities: caps({ apparmor: { support: 'unsupported', note: 'no LSM in this VM' } }), + runOnceResult: { stdout: 'foundry=fail\n', code: 0 }, + }) + + const { warns } = await runCommand(Up, { args: { name: 'env1' }, flags: {}, json: false }) + expect(warns).toEqual(['tools failed to install: foundry', 'dropped [apparmor]: no LSM in this VM']) + }) + + it('warns that tool installs are unverified when the report is unreadable', async () => { + await saveManifest( + manifest({ + name: 'env1', + spec: { name: 'env1', engine: 'auto', selections: { frameworks: ['foundry'] }, hardening: [], ssh: true }, + }), + ) + driver = new FakeDriver({ name: 'docker', displayName: 'Docker', runOnceResult: { stdout: '', code: 1 } }) + const { result, warns } = await runCommand<{ toolsVerified: boolean }>(Up, { + args: { name: 'env1' }, + flags: {}, + json: false, + }) + expect(result?.toolsVerified).toBe(false) + expect(warns).toContain('could not read the in-image tool report; install status is unverified.') + }) + + it('prints caveat warnings with a "note" prefix rather than "dropped"', async () => { + await saveManifest(hardened(['apparmor'])) + driver = new FakeDriver({ + name: 'docker', + displayName: 'Docker', + capabilities: caps({ apparmor: { support: 'caveated', note: 'best effort only' } }), + }) + const { warns } = await runCommand(Up, { args: { name: 'env1' }, flags: {}, json: false }) + expect(warns).toEqual(['note [apparmor]: best effort only']) + }) + + it('stays silent on stdout under --json', async () => { + await saveManifest(manifest({ name: 'env1' })) + const { logs, warns } = await runCommand(Up, { args: { name: 'env1' }, flags: {}, json: true }) + expect(logs).toEqual([]) + expect(warns).toEqual([]) + }) +}) diff --git a/packages/core/test/unit/coverage-gaps.test.ts b/packages/core/test/unit/coverage-gaps.test.ts new file mode 100644 index 0000000..c52a55c --- /dev/null +++ b/packages/core/test/unit/coverage-gaps.test.ts @@ -0,0 +1,258 @@ +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { CATALOG, validValuesFor, type SelectionKey } from '../../src/domain/catalog.js' +import { getProfile, isProfileKey, recipesToHardening } from '../../src/domain/profiles.js' +import { hardeningToEffects, tmpfsSizeBytes } from '../../src/hardening/effects.js' +import { emitFlags, flagNeedsUserns } from '../../src/hardening/flag-emitters.js' +import { generateContainerfile } from '../../src/containerfile/generate.js' +import { parseInstructions } from '../../src/containerfile/guard.js' +import { configHome, stateHome } from '../../src/state/paths.js' +import { useTempState } from '../helpers/fixtures.js' + +describe('catalog lookup', () => { + it('returns the option values for a known category', () => { + expect(validValuesFor('coreLanguages')).toEqual(['rust', 'python', 'go', 'node']) + }) + + it('returns an empty list for a category that does not exist', () => { + expect(validValuesFor('nope' as SelectionKey)).toEqual([]) + }) +}) + +describe('profiles', () => { + it('recognizes the shipped profile keys and rejects others', () => { + expect(isProfileKey('hardened')).toBe(true) + expect(isProfileKey('none')).toBe(false) + expect(isProfileKey('nonsense')).toBe(false) + }) + + it('looks a profile definition up by key', () => { + expect(getProfile('hardened')?.label).toBeTruthy() + expect(getProfile('nonsense')).toBeUndefined() + }) + + it('ignores unknown keys when expanding profiles', () => { + expect(recipesToHardening(['nonsense'])).toEqual([]) + expect(recipesToHardening(['hardened', 'nonsense'])).toEqual(recipesToHardening(['hardened'])) + }) +}) + +describe('hardeningToEffects precedence', () => { + it('emits only --cap-drop=ALL when both drop-caps and no-raw-packets are requested', () => { + // ALL subsumes NET_RAW; emitting both would be redundant noise on the CLI. + const effects = hardeningToEffects(['drop-caps', 'no-raw-packets']) + expect(effects.filter((e) => e.kind === 'drop-cap')).toEqual([{ kind: 'drop-cap', cap: 'ALL' }]) + }) + + it('drops NET_RAW alone when capabilities are otherwise kept', () => { + expect(hardeningToEffects(['no-raw-packets'])).toContainEqual({ kind: 'drop-cap', cap: 'NET_RAW' }) + }) + + it('lets a full air-gap supersede IPv6 sysctl tuning', () => { + const effects = hardeningToEffects(['network-none', 'disable-ipv6']) + expect(effects).toContainEqual({ kind: 'network-none' }) + expect(effects.some((e) => e.kind === 'sysctl')).toBe(false) + }) + + it('tunes IPv6 off via sysctls when there is no air-gap', () => { + const keys = hardeningToEffects(['disable-ipv6']).flatMap((e) => (e.kind === 'sysctl' ? [e.key] : [])) + expect(keys).toEqual(['net.ipv6.conf.all.disable_ipv6', 'net.ipv6.conf.default.disable_ipv6']) + }) + + it('records vscode-security as an explicit no-op with a reason', () => { + expect(hardeningToEffects(['vscode-security'])).toEqual([ + { + kind: 'noop', + source: 'vscode-security', + reason: 'Editor hardening has no effect in shell-first mode (no devcontainer/VS Code integration).', + }, + ]) + }) + + it('emits nothing for an empty selection', () => { + expect(hardeningToEffects([])).toEqual([]) + }) +}) + +describe('tmpfsSizeBytes', () => { + it('converts every size suffix the tmpfs option syntax allows', () => { + expect(tmpfsSizeBytes('rw,size=512k')).toBe(512 * 1024) + expect(tmpfsSizeBytes('rw,size=512m')).toBe(512 * 1024 ** 2) + expect(tmpfsSizeBytes('rw,size=2g')).toBe(2 * 1024 ** 3) + // A bare byte count carries no suffix. + expect(tmpfsSizeBytes('rw,size=4096')).toBe(4096) + }) + + it('is case-insensitive about the suffix', () => { + expect(tmpfsSizeBytes('rw,size=1G')).toBe(1024 ** 3) + expect(tmpfsSizeBytes('rw,size=1M')).toBe(1024 ** 2) + }) + + it('treats an unbounded tmpfs as infinitely large, so it never wins the dedupe', () => { + expect(tmpfsSizeBytes('rw,noexec,nosuid')).toBe(Number.POSITIVE_INFINITY) + }) + + it('keeps the most restrictive mount when two options target the same path', () => { + // readonly-os mounts /tmp at 1g; secure-tmp mounts it at 512m. Emitting both + // is non-deterministic, so the smaller one must survive, in place. + const tmp = hardeningToEffects(['readonly-os', 'secure-tmp']).filter( + (e) => e.kind === 'tmpfs' && e.target === '/tmp', + ) + expect(tmp).toHaveLength(1) + expect(tmp[0]).toMatchObject({ opts: expect.stringContaining('size=512m') }) + }) +}) + +describe('flag emitters', () => { + it('emits engine-correct flags for every effect kind', () => { + expect(emitFlags({ kind: 'drop-cap', cap: 'ALL' }, 'docker')).toEqual(['--cap-drop=ALL']) + expect(emitFlags({ kind: 'no-new-privs' }, 'docker')).toEqual(['--security-opt', 'no-new-privileges:true']) + expect(emitFlags({ kind: 'apparmor', profile: 'docker-default' }, 'docker')).toEqual([ + '--security-opt', + 'apparmor=docker-default', + ]) + expect(emitFlags({ kind: 'network-none' }, 'docker')).toEqual(['--network=none']) + expect(emitFlags({ kind: 'sysctl', key: 'k', value: 'v' }, 'docker')).toEqual(['--sysctl', 'k=v']) + expect(emitFlags({ kind: 'dns', servers: ['1.1.1.1', '1.0.0.1'] }, 'docker')).toEqual([ + '--dns', + '1.1.1.1', + '--dns', + '1.0.0.1', + ]) + expect(emitFlags({ kind: 'resources', memory: '2g', cpus: '4' }, 'docker')).toEqual([ + '--memory', + '2g', + '--cpus', + '4', + ]) + expect(emitFlags({ kind: 'noop', source: 'vscode-security', reason: 'y' }, 'docker')).toEqual([]) + }) + + it('recognizes uid-mapped tmpfs flags, which rootless podman must remap', () => { + expect(flagNeedsUserns('/workspace:rw,uid=1000,gid=1000')).toBe(true) + expect(flagNeedsUserns('/tmp:rw,noexec,nosuid')).toBe(false) + }) +}) + +describe('containerfile instruction parsing', () => { + it('keeps comments and blank lines as verbatim passthrough chunks, not instructions', () => { + const parsed = parseInstructions('# a comment\n\n \nRUN echo hi') + expect(parsed.map((i) => i.isRun)).toEqual([false, false, false, true]) + expect(parsed.at(-1)?.lines).toEqual(['RUN echo hi']) + }) + + it('joins a backslash continuation into a single instruction', () => { + const parsed = parseInstructions('RUN set -e \\\n && echo hi\nWORKDIR /workspace') + expect(parsed).toHaveLength(2) + expect(parsed[0]).toMatchObject({ isRun: true, lines: ['RUN set -e \\', ' && echo hi'] }) + expect(parsed[1]).toMatchObject({ isRun: false, lines: ['WORKDIR /workspace'] }) + }) + + it('recognizes every Dockerfile directive dcw emits', () => { + const directives = ['RUN', 'ENV', 'WORKDIR', 'USER', 'COPY', 'ADD', 'ARG', 'LABEL', 'SHELL', 'ENTRYPOINT', 'CMD', 'FROM'] + for (const d of directives) { + expect(parseInstructions(`${d} x`)[0]?.isRun).toBe(d === 'RUN') + } + // An unknown leading word is passthrough, never treated as an instruction. + expect(parseInstructions('NOTADIRECTIVE x')[0]?.isRun).toBe(false) + }) + + it('clones the default branch when none is pinned', () => { + const cf = generateContainerfile({ + selections: {}, + gitRepository: { enabled: true, url: 'https://github.com/foo/bar' }, + ssh: false, + }) + expect(cf).toContain('git clone https://github.com/foo/bar /home/vscode/repos/') + expect(cf).not.toContain('--branch') + }) + + it('skips the clone step entirely for a disabled git repository', () => { + const cf = generateContainerfile({ + selections: {}, + gitRepository: { enabled: false, url: 'https://github.com/foo/bar' }, + ssh: false, + }) + expect(cf).not.toContain('git clone') + }) + + it('generates a Containerfile for every catalog tool, so no snippet is missing', () => { + for (const category of CATALOG) { + for (const item of category.items) { + expect(() => + generateContainerfile({ selections: { [category.key]: [item.value] }, ssh: true }), + ).not.toThrow() + } + } + }) +}) + +describe('state paths', () => { + let state: Awaited> + + beforeEach(async () => { + state = await useTempState('dcw-paths-') + }) + + afterEach(async () => { + await state.cleanup() + }) + + it('honors the XDG environment variables when they are set', () => { + expect(configHome()).toBe(path.join(state.dir, 'config')) + expect(stateHome()).toBe(path.join(state.dir, 'state')) + }) + + it('falls back to the conventional home-relative directories', async () => { + const os = await import('node:os') + delete process.env.XDG_CONFIG_HOME + delete process.env.XDG_STATE_HOME + expect(configHome()).toBe(path.join(os.homedir(), '.config')) + expect(stateHome()).toBe(path.join(os.homedir(), '.local', 'state')) + }) +}) + +describe('manifest store failure handling', () => { + let state: Awaited> + + beforeEach(async () => { + state = await useTempState('dcw-store-') + }) + + afterEach(async () => { + await state.cleanup() + vi.restoreAllMocks() + }) + + it('propagates a read error that is not "file missing"', async () => { + const { loadManifest } = await import('../../src/state/store.js') + const { manifestPath } = await import('../../src/state/paths.js') + // A directory where the manifest should be: EISDIR, not ENOENT — a real + // failure that must surface rather than read as "no such environment". + await fs.mkdir(manifestPath('env1'), { recursive: true }) + await expect(loadManifest('env1')).rejects.toMatchObject({ code: 'EISDIR' }) + }) + + it('propagates a listing error that is not "directory missing"', async () => { + const { listManifests } = await import('../../src/state/store.js') + const { environmentsDir } = await import('../../src/state/paths.js') + await fs.mkdir(path.dirname(environmentsDir()), { recursive: true }) + await fs.writeFile(environmentsDir(), 'not a directory') + await expect(listManifests()).rejects.toMatchObject({ code: 'ENOTDIR' }) + }) + + it('returns an empty list when the environments directory does not exist yet', async () => { + const { listManifests } = await import('../../src/state/store.js') + expect(await listManifests()).toEqual([]) + }) + + it('skips non-JSON files when listing', async () => { + const { listManifests, saveManifest } = await import('../../src/state/store.js') + const { environmentsDir } = await import('../../src/state/paths.js') + const { manifest } = await import('../helpers/fixtures.js') + await saveManifest(manifest({ name: 'real' })) + await fs.writeFile(path.join(environmentsDir(), 'README.txt'), 'not a manifest') + expect((await listManifests()).map((m) => m.name)).toEqual(['real']) + }) +}) diff --git a/packages/core/test/unit/dns-probe.test.ts b/packages/core/test/unit/dns-probe.test.ts new file mode 100644 index 0000000..21eee8a --- /dev/null +++ b/packages/core/test/unit/dns-probe.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { CaptureResult } from '../../src/engine/exec.js' + +let script: Array<{ match: RegExp; result: Partial }> = [] + +vi.mock('../../src/engine/exec.js', () => ({ + capture: async (bin: string, args: string[]): Promise => { + const hit = script.find((s) => s.match.test(`${bin} ${args.join(' ')}`)) + return { code: 0, stdout: '', stderr: '', spawnError: false, ...(hit?.result ?? {}) } + }, + inherit: async () => 0, +})) + +const { parsePort53Listeners, probeAppleBuildDns } = await import('../../src/engine/dns-preflight.js') + +const INSPECT = JSON.stringify([{ configuration: { dns: { nameservers: ['192.168.64.1'] } } }]) +const NETWORK = JSON.stringify([{ status: { ipv4Gateway: '192.168.64.1' } }]) +const NETSTAT = 'Proto Recv-Q Send-Q Local Address\nudp4 0 0 127.0.0.1.53\n' + +function healthy() { + script = [ + { match: /^container inspect buildkit/, result: { stdout: INSPECT } }, + { match: /^container network inspect default/, result: { stdout: NETWORK } }, + { match: /^netstat/, result: { stdout: NETSTAT } }, + ] +} + +beforeEach(() => { + healthy() +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('parsePort53Listeners', () => { + it('splits the macOS `.` local address on its LAST dot', () => { + const rows = [ + 'udp4 0 0 127.0.0.2.53 *.*', + 'udp6 0 0 fe80::1%lo0.53 *.*', + 'udp4 0 0 *.53 *.*', + ].join('\n') + expect(parsePort53Listeners(rows)).toEqual([ + { family: 'inet', address: '127.0.0.2' }, + { family: 'inet6', address: 'fe80::1%lo0' }, + { family: 'inet', address: '*' }, + ]) + }) + + it('ignores rows that are not UDP, not port 53, short, or missing a port suffix', () => { + const rows = [ + 'tcp4 0 0 127.0.0.1.53 *.*', + 'udp4 0 0 127.0.0.1.5353 *.*', + 'udp4 0 0', + 'udp4 0 0 no-port-here *.*', + '', + ].join('\n') + expect(parsePort53Listeners(rows)).toEqual([]) + }) +}) + +describe('probeAppleBuildDns', () => { + it('diagnoses a blocked builder when a non-gateway process squats on port 53', async () => { + const diag = await probeAppleBuildDns() + expect(diag).toMatchObject({ blocked: true, gatewayIpv4: '192.168.64.1' }) + }) + + it('returns null — never a confident diagnosis — when any probe command fails', async () => { + for (const failing of [/^container inspect/, /^container network inspect/, /^netstat/]) { + healthy() + script.push({ match: failing, result: { code: 1 } }) + script = [script.at(-1)!, ...script.slice(0, -1)] + expect(await probeAppleBuildDns()).toBeNull() + } + }) + + it('returns null when the builder DNS config cannot be parsed', async () => { + healthy() + script.unshift({ match: /^container inspect buildkit/, result: { stdout: 'not json' } }) + expect(await probeAppleBuildDns()).toBeNull() + }) + + it('returns null when the network gateway cannot be parsed', async () => { + healthy() + script.unshift({ match: /^container network inspect default/, result: { stdout: '[]' } }) + expect(await probeAppleBuildDns()).toBeNull() + }) + + it('returns null rather than accusing a healthy host when netstat is unreadable', async () => { + healthy() + script.unshift({ match: /^netstat/, result: { stdout: 'unparseable output\n' } }) + expect(await probeAppleBuildDns()).toBeNull() + }) + + it('honors a custom CLI binary name', async () => { + script = [ + { match: /^mycontainer inspect buildkit/, result: { stdout: INSPECT } }, + { match: /^mycontainer network inspect default/, result: { stdout: NETWORK } }, + { match: /^netstat/, result: { stdout: NETSTAT } }, + ] + expect(await probeAppleBuildDns('mycontainer')).not.toBeNull() + }) +}) diff --git a/packages/core/test/unit/edge-cases.test.ts b/packages/core/test/unit/edge-cases.test.ts new file mode 100644 index 0000000..ea65bec --- /dev/null +++ b/packages/core/test/unit/edge-cases.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it } from 'vitest' +import { parseAppleList } from '../../src/engine/drivers/apple-container.js' +import { parsePsJson } from '../../src/engine/drivers/cli-driver.js' +import { parseBuilderNameservers, parseNetworkGateway } from '../../src/engine/dns-preflight.js' +import { caps } from '../../src/engine/drivers/capabilities.js' +import { enginePlatformSupport, enginePreference } from '../../src/engine/host.js' +import { resolveEngine } from '../../src/engine/resolver.js' +import { inherit } from '../../src/engine/exec.js' +import { capabilityFor } from '../../src/hardening/effects.js' +import { enforceStrict, translate } from '../../src/hardening/translator.js' +import { renderEntry } from '../../src/core/ssh/ssh-config.js' +import { upEnvironment } from '../../src/core/up-pipeline.js' +import { planEnvironment } from '../../src/core/plan.js' +import { EnvSpecSchema } from '../../src/spec/env-spec.js' +import { FakeDriver } from '../engine/fake-driver.js' +import { manifest } from '../helpers/fixtures.js' + +describe('host support fallbacks', () => { + it('refuses an engine name it does not know', () => { + expect(enginePlatformSupport('nerdctl' as never, { os: 'linux', arch: 'x64' })).toEqual({ + supported: false, + reason: 'Unknown engine.', + }) + }) + + it('offers a preference order for every host, narrowing off macOS and Linux', () => { + expect(enginePreference({ os: 'macos', arch: 'arm64' })).toEqual([ + 'orbstack', + 'docker', + 'podman', + 'apple-container', + 'lima', + ]) + expect(enginePreference({ os: 'linux', arch: 'x64' })).toEqual(['docker', 'podman', 'lima']) + // Windows and unknown hosts get only the engines that are portable. + expect(enginePreference({ os: 'windows', arch: 'x64' })).toEqual(['docker', 'podman']) + expect(enginePreference({ os: 'other', arch: 'other' })).toEqual(['docker', 'podman']) + }) +}) + +describe('resolveEngine', () => { + const host = { os: 'linux' as const, arch: 'x64' as const } + + it('rejects a requested engine that has no driver', async () => { + await expect(resolveEngine({ requested: 'nerdctl', host, drivers: [] })).rejects.toMatchObject({ + code: 'E_ENGINE_UNSUPPORTED', + message: "Unknown engine 'nerdctl'.", + }) + }) + + it('rejects a requested engine the host cannot run', async () => { + const orbstack = new FakeDriver({ name: 'orbstack' }) + await expect(resolveEngine({ requested: 'orbstack', host, drivers: [orbstack] })).rejects.toMatchObject({ + code: 'E_ENGINE_UNSUPPORTED', + message: expect.stringContaining('OrbStack runs on macOS only.'), + }) + }) + + it('falls back to a generic reason when a requested engine reports none', async () => { + const docker = new FakeDriver({ name: 'docker', detect: { available: false } }) + await expect(resolveEngine({ requested: 'docker', host, drivers: [docker] })).rejects.toMatchObject({ + code: 'E_ENGINE_UNAVAILABLE', + message: "Engine 'docker' is supported but not available: not detected.", + }) + }) + + it('auto-detects the first available engine in host preference order', async () => { + const docker = new FakeDriver({ name: 'docker', detect: { available: false, reason: 'daemon down' } }) + const podman = new FakeDriver({ name: 'podman' }) + const resolved = await resolveEngine({ host, drivers: [podman, docker] }) + // Linux prefers docker, but it is down, so podman wins. + expect(resolved.driver).toBe(podman) + }) + + it('skips engines with no driver registered and engines this host cannot run', async () => { + // OrbStack is macOS-only, so on Linux it must be skipped rather than probed. + const orbstack = new FakeDriver({ name: 'orbstack' }) + const podman = new FakeDriver({ name: 'podman' }) + const resolved = await resolveEngine({ host, drivers: [orbstack, podman] }) + expect(resolved.driver).toBe(podman) + }) + + it('lists what it tried when nothing is available', async () => { + const docker = new FakeDriver({ name: 'docker', displayName: 'Docker', detect: { available: false, reason: 'daemon down' } }) + // A driver with no reason still gets a readable entry. + const podman = new FakeDriver({ name: 'podman', displayName: 'Podman', detect: { available: false } }) + await expect(resolveEngine({ host, drivers: [docker, podman] })).rejects.toMatchObject({ + code: 'E_NO_ENGINE', + message: expect.stringContaining('Tried: Docker (daemon down), Podman (not detected).'), + }) + }) + + it("reports 'none' when there were no candidate engines at all", async () => { + await expect(resolveEngine({ host, drivers: [] })).rejects.toMatchObject({ + message: expect.stringContaining('Tried: none.'), + }) + }) +}) + +describe('inherit', () => { + it('reports 0 for a child killed by a signal, which exits with a null code', async () => { + expect(await inherit(process.execPath, ['-e', 'process.kill(process.pid, "SIGKILL")'])).toBe(0) + }) +}) + +describe('capabilityFor', () => { + it('maps every hardening effect kind to the capability that governs it', () => { + expect(capabilityFor({ kind: 'readonly-rootfs' })).toBe('readOnlyRootfs') + expect(capabilityFor({ kind: 'tmpfs', target: '/tmp', opts: 'rw' })).toBe('tmpfs') + expect(capabilityFor({ kind: 'ephemeral-workspace' })).toBe('tmpfs') + expect(capabilityFor({ kind: 'drop-cap', cap: 'ALL' })).toBe('capDrop') + expect(capabilityFor({ kind: 'no-new-privs' })).toBe('noNewPrivs') + expect(capabilityFor({ kind: 'apparmor', profile: 'docker-default' })).toBe('apparmor') + expect(capabilityFor({ kind: 'network-none' })).toBe('networkNone') + expect(capabilityFor({ kind: 'sysctl', key: 'k', value: 'v' })).toBe('sysctl') + expect(capabilityFor({ kind: 'dns', servers: ['1.1.1.1'] })).toBe('dns') + expect(capabilityFor({ kind: 'resources', memory: '2g', cpus: '4' })).toBe('memoryLimit') + // A no-op has no capability to consult; it only ever produces an advisory. + expect(capabilityFor({ kind: 'noop', source: 'vscode-security', reason: 'y' })).toBeNull() + }) +}) + +describe('translate warning labels', () => { + it('names the specific target of a dropped tmpfs, cap-drop or sysctl', () => { + const effects = [ + { kind: 'tmpfs' as const, target: '/tmp', opts: 'rw' }, + { kind: 'drop-cap' as const, cap: 'NET_RAW' as const }, + { kind: 'sysctl' as const, key: 'net.ipv6.conf.all.disable_ipv6', value: '1' }, + ] + const result = translate( + effects, + caps({ + tmpfs: { support: 'unsupported' }, + capDrop: { support: 'unsupported' }, + sysctl: { support: 'unsupported' }, + }), + 'apple-container', + ) + + expect(result.warnings.map((w) => w.effect)).toEqual([ + 'tmpfs /tmp', + 'drop-cap NET_RAW', + 'sysctl net.ipv6.conf.all.disable_ipv6', + ]) + // With no per-capability note, the message names the engine that dropped it. + expect(result.warnings[0]?.message).toBe('apple-container does not support this hardening; it was dropped.') + expect(() => enforceStrict(result)).toThrow(/tmpfs \/tmp, drop-cap NET_RAW, sysctl net\.ipv6/) + }) + + it('emits a caveat with no warning text when the engine gave no note', () => { + const result = translate( + [{ kind: 'apparmor', profile: 'docker-default' }], + caps({ apparmor: { support: 'caveated', enforced: false } }), + 'podman', + ) + expect(result.warnings).toEqual([]) + expect(result.flags).toEqual(['--security-opt', 'apparmor=docker-default']) + // Unenforced still blocks --strict, note or no note. + expect(result.unenforced.map((e) => e.kind)).toEqual(['apparmor']) + }) + + it('treats a caveat as advisory when the engine does enforce it', () => { + const result = translate( + [{ kind: 'apparmor', profile: 'docker-default' }], + caps({ apparmor: { support: 'caveated', note: 'heads up' } }), + 'docker', + ) + expect(result.unenforced).toEqual([]) + expect(() => enforceStrict(result)).not.toThrow() + }) +}) + +describe('upEnvironment preconditions', () => { + it('refuses to start an environment that has no built image', async () => { + const plan = planEnvironment(EnvSpecSchema.parse({ name: 'env1' })) + await expect( + upEnvironment({ + manifest: manifest({ name: 'env1' }), + plan, + driver: new FakeDriver({ name: 'docker' }), + capabilities: caps(), + engineName: 'docker', + workspaceDir: '/ws', + now: '2026-06-11T00:00:00.000Z', + }), + ).rejects.toThrow("Environment 'env1' has no built image. Run 'dcw build env1' first.") + }) +}) + +describe('ssh config rendering', () => { + it('falls back to the container SSH port when a port entry records none', () => { + const rendered = renderEntry({ + name: 'env1', + mode: 'port', + identityFile: '/k', + knownHostsFile: '/kh', + }) + expect(rendered).toContain('HostName localhost') + expect(rendered).toContain('Port 2222') + }) + + it('pins host-key checking to dcw\'s own known_hosts, never the user\'s', () => { + const rendered = renderEntry({ + name: 'env1', + mode: 'exec', + identityFile: '/k', + knownHostsFile: '/kh', + proxyCommand: 'dcw ssh-proxy env1', + }) + expect(rendered).toContain('UserKnownHostsFile /kh') + expect(rendered).toContain('IdentitiesOnly yes') + expect(rendered).toContain('StrictHostKeyChecking accept-new') + }) +}) + +describe('ps parsing fallbacks', () => { + it('tolerates a docker row with no id at all', () => { + expect(parsePsJson('{}')).toEqual([{ id: '', name: '', image: '', status: '', labels: {} }]) + }) + + it('drops Apple rows with no usable id, and rows that are not objects', () => { + const rows = JSON.stringify([null, 'nonsense', {}, { configuration: { id: 'from-config' } }]) + expect(parseAppleList(rows).map((c) => c.id)).toEqual(['from-config']) + }) + + it('defaults Apple image and status when the row omits them', () => { + expect(parseAppleList(JSON.stringify([{ id: 'x' }]))[0]).toEqual({ + id: 'x', + name: 'x', + image: '', + status: '', + labels: {}, + }) + }) + + it('stringifies non-string Apple label values', () => { + const rows = JSON.stringify([{ id: 'x', configuration: { labels: { n: 1, b: true } } }]) + expect(parseAppleList(rows)[0]?.labels).toEqual({ n: '1', b: 'true' }) + }) + + it('filters Apple rows by bare label presence as well as key=value', () => { + const rows = JSON.stringify([ + { id: 'a', configuration: { labels: { 'dcw.env': 'a' } } }, + { id: 'b', configuration: { labels: {} } }, + ]) + expect(parseAppleList(rows, 'dcw.env').map((c) => c.id)).toEqual(['a']) + expect(parseAppleList(rows, 'dcw.env=b')).toEqual([]) + }) + + it('returns nothing for output that is not a JSON array', () => { + expect(parseAppleList('not json')).toEqual([]) + expect(parseAppleList('{"id":"x"}')).toEqual([]) + }) +}) + +describe('Apple DNS preflight parsing', () => { + it('returns null for output that is not a non-empty JSON array', () => { + for (const bad of ['not json', '{}', '[]']) { + expect(parseBuilderNameservers(bad)).toBeNull() + expect(parseNetworkGateway(bad)).toBeNull() + } + }) + + it('reads the builder nameservers, treating absent DNS config as "none set"', () => { + expect(parseBuilderNameservers(JSON.stringify([{ configuration: { dns: { nameservers: ['1.1.1.1'] } } }]))).toEqual([ + '1.1.1.1', + ]) + expect(parseBuilderNameservers(JSON.stringify([{ configuration: {} }]))).toEqual([]) + expect(parseBuilderNameservers(JSON.stringify([{}]))).toEqual([]) + expect(parseBuilderNameservers(JSON.stringify([{ configuration: { dns: null } }]))).toEqual([]) + expect(parseBuilderNameservers(JSON.stringify([{ configuration: { dns: { nameservers: null } } }]))).toEqual([]) + expect(parseBuilderNameservers(JSON.stringify([{ configuration: { dns: {} } }]))).toEqual([]) + expect(parseBuilderNameservers(JSON.stringify([{ configuration: { dns: 'weird' } }]))).toBeNull() + expect(parseBuilderNameservers(JSON.stringify([{ configuration: { dns: { nameservers: 'weird' } } }]))).toBeNull() + }) + + it('reads the network gateway only when it is a non-empty string', () => { + expect(parseNetworkGateway(JSON.stringify([{ status: { ipv4Gateway: '192.168.64.1' } }]))).toBe('192.168.64.1') + expect(parseNetworkGateway(JSON.stringify([{ status: { ipv4Gateway: '' } }]))).toBeNull() + expect(parseNetworkGateway(JSON.stringify([{ status: {} }]))).toBeNull() + expect(parseNetworkGateway(JSON.stringify([{}]))).toBeNull() + }) +}) diff --git a/packages/core/test/unit/engine-exec.test.ts b/packages/core/test/unit/engine-exec.test.ts new file mode 100644 index 0000000..7695cbb --- /dev/null +++ b/packages/core/test/unit/engine-exec.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { capture, inherit } from '../../src/engine/exec.js' + +const NODE = process.execPath + +describe('capture', () => { + it('collects stdout and the exit code', async () => { + const res = await capture(NODE, ['-e', 'process.stdout.write("hello")']) + expect(res).toEqual({ code: 0, stdout: 'hello', stderr: '', spawnError: false }) + }) + + it('collects stderr and a non-zero exit code without throwing', async () => { + const res = await capture(NODE, ['-e', 'process.stderr.write("boom"); process.exit(3)']) + expect(res).toMatchObject({ code: 3, stderr: 'boom', spawnError: false }) + }) + + it('reports spawnError with code 127 when the binary does not exist', async () => { + const res = await capture('definitely-not-a-real-binary-xyz', []) + expect(res.spawnError).toBe(true) + expect(res.code).toBe(127) + // The spawn error message stands in for stderr, so callers have something to show. + expect(res.stderr).not.toBe('') + }) + + it('feeds stdin when input is supplied', async () => { + const res = await capture(NODE, ['-e', 'process.stdin.pipe(process.stdout)'], { input: 'piped-in' }) + expect(res.stdout).toBe('piped-in') + }) + + it('passes a custom environment and working directory', async () => { + const res = await capture(NODE, ['-e', 'process.stdout.write(process.env.DCW_T + "|" + process.cwd())'], { + env: { ...process.env, DCW_T: 'x' }, + cwd: '/tmp', + }) + const [value, cwd] = res.stdout.split('|') + expect(value).toBe('x') + // macOS resolves /tmp through a symlink, so compare on the suffix. + expect(cwd?.endsWith('/tmp')).toBe(true) + }) + + it('treats a signalled child (null exit code) as exit 0 rather than crashing', async () => { + const res = await capture(NODE, ['-e', 'process.kill(process.pid, "SIGKILL")']) + expect(typeof res.code).toBe('number') + expect(res.spawnError).toBe(false) + }) +}) + +describe('inherit', () => { + it('resolves with the child exit code', async () => { + expect(await inherit(NODE, ['-e', 'process.exit(0)'])).toBe(0) + expect(await inherit(NODE, ['-e', 'process.exit(5)'])).toBe(5) + }) + + it('resolves with 127 when the binary cannot be spawned', async () => { + expect(await inherit('definitely-not-a-real-binary-xyz', [])).toBe(127) + }) + + it('passes a custom environment and working directory', async () => { + expect( + await inherit(NODE, ['-e', 'process.exit(process.env.DCW_T === "9" && process.cwd().endsWith("/tmp") ? 0 : 1)'], { + env: { ...process.env, DCW_T: '9' }, + cwd: '/tmp', + }), + ).toBe(0) + }) +}) diff --git a/packages/core/test/unit/engine-host-detect.test.ts b/packages/core/test/unit/engine-host-detect.test.ts new file mode 100644 index 0000000..da56ae9 --- /dev/null +++ b/packages/core/test/unit/engine-host-detect.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { CaptureResult } from '../../src/engine/exec.js' + +let captureResult: Partial = {} +const captureCalls: Array<{ bin: string; args: string[] }> = [] + +vi.mock('../../src/engine/exec.js', () => ({ + capture: async (bin: string, args: string[]): Promise => { + captureCalls.push({ bin, args }) + return { code: 0, stdout: '', stderr: '', spawnError: false, ...captureResult } + }, + inherit: async () => 0, +})) + +const { describeHost, detectHost } = await import('../../src/engine/host.js') + +function withPlatform(platform: NodeJS.Platform, arch: string, fn: () => Promise): Promise { + const prev = { platform: process.platform, arch: process.arch } + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) + Object.defineProperty(process, 'arch', { value: arch, configurable: true }) + return fn().finally(() => { + Object.defineProperty(process, 'platform', { value: prev.platform, configurable: true }) + Object.defineProperty(process, 'arch', { value: prev.arch, configurable: true }) + }) +} + +beforeEach(() => { + captureCalls.length = 0 + captureResult = {} +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('detectHost', () => { + it('maps each supported platform and architecture', async () => { + await withPlatform('linux', 'x64', async () => { + expect(await detectHost()).toEqual({ os: 'linux', arch: 'x64' }) + }) + await withPlatform('win32', 'arm64', async () => { + expect(await detectHost()).toEqual({ os: 'windows', arch: 'arm64' }) + }) + await withPlatform('freebsd', 'ppc64', async () => { + expect(await detectHost()).toEqual({ os: 'other', arch: 'other' }) + }) + }) + + it('reads the major macOS version from sw_vers', async () => { + captureResult = { stdout: '15.6.1\n' } + await withPlatform('darwin', 'arm64', async () => { + expect(await detectHost()).toEqual({ os: 'macos', arch: 'arm64', macosMajor: 15 }) + }) + expect(captureCalls).toEqual([{ bin: 'sw_vers', args: ['-productVersion'] }]) + }) + + it('omits the version rather than guessing when sw_vers fails or is unparseable', async () => { + for (const result of [ + { spawnError: true, code: 127 }, + { code: 1 }, + { stdout: 'not-a-version\n' }, + { stdout: '\n' }, + ]) { + captureResult = result + await withPlatform('darwin', 'arm64', async () => { + expect(await detectHost()).toEqual({ os: 'macos', arch: 'arm64' }) + }) + } + }) + + it('never probes sw_vers off macOS', async () => { + await withPlatform('linux', 'x64', async () => { + await detectHost() + }) + expect(captureCalls).toEqual([]) + }) +}) + +describe('describeHost', () => { + it('labels each OS, including the macOS major version when known', () => { + expect(describeHost({ os: 'macos', arch: 'arm64', macosMajor: 15 })).toBe('macOS 15 (arm64)') + expect(describeHost({ os: 'macos', arch: 'x64' })).toBe('macOS (x64)') + expect(describeHost({ os: 'linux', arch: 'x64' })).toBe('Linux (x64)') + expect(describeHost({ os: 'windows', arch: 'x64' })).toBe('Windows (x64)') + expect(describeHost({ os: 'other', arch: 'other' })).toBe('unknown OS (other)') + }) +}) diff --git a/packages/core/test/unit/engine-registry.test.ts b/packages/core/test/unit/engine-registry.test.ts new file mode 100644 index 0000000..46d692d --- /dev/null +++ b/packages/core/test/unit/engine-registry.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { ALL_ENGINES, createAllDrivers, createDriver } from '../../src/engine/registry.js' + +describe('engine registry', () => { + it('creates a driver for every declared engine name', () => { + for (const name of ALL_ENGINES) { + expect(createDriver(name).name).toBe(name) + } + }) + + it('creates the full driver set, in declaration order', () => { + expect(createAllDrivers().map((d) => d.name)).toEqual([...ALL_ENGINES]) + }) + + it('gives every driver a display name and a complete capability map', () => { + for (const driver of createAllDrivers()) { + expect(driver.displayName).toBeTruthy() + expect(Object.keys(driver.capabilities).length).toBeGreaterThan(0) + } + }) + + it('refuses an unknown engine name rather than returning a half-driver', () => { + // The exhaustive `never` switch makes this unreachable from typed callers; + // it still has to hold for values that arrive from JSON/manifests at runtime. + expect(() => createDriver('nerdctl' as never)).toThrow('Unknown engine: nerdctl') + }) +}) diff --git a/packages/core/test/unit/free-port.test.ts b/packages/core/test/unit/free-port.test.ts new file mode 100644 index 0000000..1a503a3 --- /dev/null +++ b/packages/core/test/unit/free-port.test.ts @@ -0,0 +1,49 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' + +interface FakeServer extends EventEmitter { + listen: (port: number, host: string, cb: () => void) => void + address: () => unknown + close: (cb: () => void) => void +} + +let addressResult: unknown = { port: 4321 } +let listenError: Error | undefined + +vi.mock('node:net', () => ({ + createServer: (): FakeServer => { + const srv = new EventEmitter() as FakeServer + srv.listen = (_port, _host, cb) => { + if (listenError) { + srv.emit('error', listenError) + return + } + cb() + } + srv.address = () => addressResult + srv.close = (cb: () => void) => cb() + return srv + }, +})) + +const { findFreePort } = await import('../../src/core/ssh/attach.js') + +describe('findFreePort failure modes', () => { + it('reads the port back off the bound loopback socket', async () => { + addressResult = { port: 4321 } + listenError = undefined + expect(await findFreePort()).toBe(4321) + }) + + it('rejects rather than guessing when the socket reports no numeric address', async () => { + addressResult = '/tmp/some.sock' + listenError = undefined + await expect(findFreePort()).rejects.toThrow('Could not determine a free port.') + }) + + it('rejects when the socket cannot be bound at all', async () => { + addressResult = { port: 1 } + listenError = new Error('EADDRINUSE') + await expect(findFreePort()).rejects.toThrow('EADDRINUSE') + }) +}) diff --git a/packages/core/test/unit/generate-guard.test.ts b/packages/core/test/unit/generate-guard.test.ts new file mode 100644 index 0000000..fe73b4a --- /dev/null +++ b/packages/core/test/unit/generate-guard.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest' + +// Drop one tool's install command while leaving it a recognized tool key: the +// state a catalog/INSTALL_COMMANDS mismatch would produce. +vi.mock('../../src/domain/install-commands.js', async (orig) => { + const actual = await orig() + const { foundry, ...rest } = actual.INSTALL_COMMANDS as Record + return { ...actual, INSTALL_COMMANDS: rest } +}) + +const { generateContainerfile } = await import('../../src/containerfile/generate.js') + +describe('generateContainerfile snippet lookup', () => { + it('fails loudly rather than emitting an image missing a requested tool', async () => { + expect(() => generateContainerfile({ selections: { frameworks: ['foundry'] }, ssh: false })).toThrow( + "No install command for tool 'foundry'.", + ) + }) + + it('still generates cleanly for tools whose snippets are present', () => { + expect(generateContainerfile({ selections: { coreLanguages: ['go'] }, ssh: false })).toContain('WORKDIR /workspace') + }) +}) diff --git a/packages/core/test/unit/package-entry.test.ts b/packages/core/test/unit/package-entry.test.ts new file mode 100644 index 0000000..00c5f9d --- /dev/null +++ b/packages/core/test/unit/package-entry.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import * as api from '../../src/index.js' +import { BaseCommand } from '../../src/base-command.js' +import { SKILL_PATH, readSkill, skillName } from '../../src/skill.js' + +describe('package entry point', () => { + it('re-exports BaseCommand as the public API surface', () => { + expect(Object.keys(api)).toEqual(['BaseCommand']) + expect(api.BaseCommand).toBe(BaseCommand) + }) +}) + +describe('skill loader', () => { + it('resolves SKILL.md relative to the module, so it works from src/ and dist/', () => { + expect(SKILL_PATH.endsWith('/skill/SKILL.md')).toBe(true) + expect(readSkill()).toContain('name: dcw') + }) + + it('reads the skill name out of the frontmatter', () => { + expect(skillName(readSkill())).toBe('dcw') + expect(skillName('---\nname: custom\ndescription: x\n---\nbody')).toBe('custom') + }) + + it("falls back to 'dcw' when there is no parseable frontmatter name", () => { + expect(skillName('no frontmatter here')).toBe('dcw') + expect(skillName('---\ndescription: x\n---\n')).toBe('dcw') + }) +}) diff --git a/packages/core/test/unit/resolver-defaults.test.ts b/packages/core/test/unit/resolver-defaults.test.ts new file mode 100644 index 0000000..c7e97a3 --- /dev/null +++ b/packages/core/test/unit/resolver-defaults.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CaptureResult } from '../../src/engine/exec.js' +import { ALL_ENGINES } from '../../src/engine/types.js' + +// Nothing is installed: every version/info probe spawn-fails, so the real driver +// registry can be exercised without touching the developer's actual engines. +vi.mock('../../src/engine/exec.js', () => ({ + capture: async (): Promise => ({ + code: 127, + stdout: '', + stderr: 'not found', + spawnError: true, + }), + inherit: async () => 127, +})) + +const { resolveEngine, surveyEngines } = await import('../../src/engine/resolver.js') + +const macIntel = { os: 'macos' as const, arch: 'x64' as const, macosMajor: 15 } + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('resolver defaults to the real driver registry', () => { + it('surveys every registered engine when no drivers are injected', async () => { + const statuses = await surveyEngines({ host: macIntel }) + expect(statuses.map((s) => s.name).sort()).toEqual([...ALL_ENGINES].sort()) + // Apple Containers needs Apple Silicon, so it is unsupported on an Intel Mac + // and is never probed. + const apple = statuses.find((s) => s.name === 'apple-container')! + expect(apple.platform.supported).toBe(false) + expect(apple.detect).toBeUndefined() + expect(statuses.every((s) => !s.recommended)).toBe(true) + }) + + it('reports no engine available when none is installed', async () => { + await expect(resolveEngine({ host: macIntel })).rejects.toMatchObject({ code: 'E_NO_ENGINE' }) + }) + + it('skips a platform-unsupported engine while auto-detecting', async () => { + // apple-container sits in the macOS preference order but cannot run on x64, + // so it must not appear among the engines that were actually tried. + const err = await resolveEngine({ host: macIntel }).catch((e: Error) => e) + const tried = /Tried: (.*?)\. Install/.exec((err as Error).message)?.[1] ?? '' + expect(tried).not.toContain('Apple Containers') + expect(tried).toContain('Docker') + }) + + it('reports a requested-but-missing engine with the reason the driver gave', async () => { + await expect(resolveEngine({ requested: 'docker', host: macIntel })).rejects.toMatchObject({ + code: 'E_ENGINE_UNAVAILABLE', + message: expect.stringContaining('docker not found on PATH.'), + }) + }) +}) diff --git a/packages/core/test/unit/ssh-core.test.ts b/packages/core/test/unit/ssh-core.test.ts new file mode 100644 index 0000000..2a71b24 --- /dev/null +++ b/packages/core/test/unit/ssh-core.test.ts @@ -0,0 +1,164 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ContainerInfo, EngineDriver } from '../../src/engine/types.js' + +const isOnPath = vi.fn(async (_bin: string) => false) +const spawn = vi.fn() + +vi.mock('../../src/util/which.js', () => ({ isOnPath: (bin: string) => isOnPath(bin) })) +vi.mock('node:child_process', async (orig) => { + const actual = await orig() + return { ...actual, spawn: (...a: unknown[]) => spawn(...a) } +}) + +const { findFreePort, isContainerRunning, resolveDcwInvocation } = await import('../../src/core/ssh/attach.js') +const { EDITOR_IDS, detectEditor, editorDisplayName, launchEditor } = await import('../../src/core/ssh/editors.js') + +function fakeChild() { + const child = new EventEmitter() as EventEmitter & { unref: () => void } + child.unref = vi.fn() + return child +} + +beforeEach(() => { + isOnPath.mockReset().mockResolvedValue(false) + spawn.mockReset().mockImplementation(() => fakeChild()) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('isContainerRunning', () => { + function driverWith(containers: ContainerInfo[]) { + return { + ps: vi.fn(async () => containers), + } as unknown as EngineDriver + } + + const info = (status: string): ContainerInfo => ({ id: 'c', name: 'dcw-env1', image: 'i', status, labels: {} }) + + it('queries by the dcw environment label, including stopped containers', async () => { + const driver = driverWith([]) + await isContainerRunning(driver, 'env1') + expect(driver.ps).toHaveBeenCalledWith({ label: 'dcw.env=env1', all: true }) + }) + + it('recognizes both docker-style "Up …" and plain "running" statuses', async () => { + expect(await isContainerRunning(driverWith([info('Up 2 minutes')]), 'env1')).toBe(true) + expect(await isContainerRunning(driverWith([info('running')]), 'env1')).toBe(true) + }) + + it('reports false for stopped or missing containers', async () => { + expect(await isContainerRunning(driverWith([info('Exited (0) 3 minutes ago')]), 'env1')).toBe(false) + expect(await isContainerRunning(driverWith([]), 'env1')).toBe(false) + }) + + it('does not mistake a substring like "backup" for a running container', async () => { + expect(await isContainerRunning(driverWith([info('backup pending')]), 'env1')).toBe(false) + }) +}) + +describe('findFreePort', () => { + it('returns a bindable loopback port', async () => { + const port = await findFreePort() + expect(port).toBeGreaterThan(0) + expect(port).toBeLessThan(65536) + // Two calls must not hand out the same port while the first is still open. + expect(typeof (await findFreePort())).toBe('number') + }) +}) + +describe('resolveDcwInvocation', () => { + it('prefers `dcw` on PATH so the ProxyCommand survives upgrades', async () => { + isOnPath.mockResolvedValue(true) + expect(await resolveDcwInvocation('my-env')).toBe('dcw ssh-proxy my-env') + }) + + it('falls back to the running node + entry script, shell-quoted', async () => { + // ssh runs ProxyCommand through `/bin/sh -c`, so a path with spaces would split. + const prev = process.argv[1] + process.argv[1] = '/Users/First Last/dcw/bin/run.js' + try { + const inv = await resolveDcwInvocation('my-env') + expect(inv).toBe(`'${process.execPath}' '/Users/First Last/dcw/bin/run.js' ssh-proxy my-env`) + } finally { + process.argv[1] = prev as string + } + }) + + it("escapes embedded single quotes in the fallback path", async () => { + const prev = process.argv[1] + process.argv[1] = "/tmp/it's/run.js" + try { + expect(await resolveDcwInvocation('e')).toContain(`'/tmp/it'\\''s/run.js'`) + } finally { + process.argv[1] = prev as string + } + }) + + it('falls back to a bare `dcw` invocation when there is no entry script', async () => { + const prev = process.argv[1] + // @ts-expect-error deliberately simulating an argv with no script path + process.argv[1] = undefined + try { + expect(await resolveDcwInvocation('e')).toBe('dcw ssh-proxy e') + } finally { + process.argv[1] = prev as string + } + }) +}) + +describe('editors', () => { + it('exposes every supported editor id', () => { + expect(EDITOR_IDS).toEqual(['zed', 'vscode', 'cursor', 'antigravity']) + }) + + it('maps ids to display names', () => { + expect(EDITOR_IDS.map(editorDisplayName)).toEqual(['Zed', 'VS Code', 'Cursor', 'Antigravity']) + }) + + it('detects the first installed editor in preference order', async () => { + isOnPath.mockImplementation(async (bin) => bin === 'cursor') + expect(await detectEditor()).toBe('cursor') + }) + + it('returns undefined when no editor CLI is on PATH', async () => { + expect(await detectEditor()).toBeUndefined() + }) + + it('launches Zed with an ssh:// URL', async () => { + isOnPath.mockResolvedValue(true) + expect(await launchEditor({ editor: 'zed', alias: 'dcw-env1', folder: '/workspace' })).toBe(true) + expect(spawn).toHaveBeenCalledWith('zed', ['ssh://dcw-env1/workspace'], { detached: true, stdio: 'ignore' }) + }) + + it('launches the VS Code family with --remote ssh-remote+', async () => { + isOnPath.mockResolvedValue(true) + for (const [editor, bin] of [ + ['vscode', 'code'], + ['cursor', 'cursor'], + ['antigravity', 'antigravity'], + ] as const) { + spawn.mockClear() + await launchEditor({ editor, alias: 'dcw-env1', folder: '/src' }) + expect(spawn).toHaveBeenCalledWith(bin, ['--remote', 'ssh-remote+dcw-env1', '/src'], { + detached: true, + stdio: 'ignore', + }) + } + }) + + it('detaches the child so dcw can exit immediately', async () => { + isOnPath.mockResolvedValue(true) + const child = fakeChild() + spawn.mockReturnValue(child) + await launchEditor({ editor: 'zed', alias: 'a', folder: '/workspace' }) + expect(child.unref).toHaveBeenCalledOnce() + }) + + it('reports false, without spawning, when the editor CLI is missing', async () => { + expect(await launchEditor({ editor: 'zed', alias: 'a', folder: '/workspace' })).toBe(false) + expect(spawn).not.toHaveBeenCalled() + }) +}) diff --git a/packages/core/test/unit/ssh-provision.test.ts b/packages/core/test/unit/ssh-provision.test.ts new file mode 100644 index 0000000..fae4ea3 --- /dev/null +++ b/packages/core/test/unit/ssh-provision.test.ts @@ -0,0 +1,191 @@ +import * as fs from 'node:fs/promises' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SSHD_CONFIG_PATH } from '../../src/containerfile/base.js' +import { hostKeyPath, knownHostsPath } from '../../src/core/ssh/keys.js' +import { provisionContainerSsh, startSshDaemon } from '../../src/core/ssh/provision.js' +import type { CaptureResult } from '../../src/engine/exec.js' +import type { EngineDriver, ExecSpec } from '../../src/engine/types.js' +import { useTempState } from '../helpers/fixtures.js' + +interface Call { + spec: ExecSpec + input?: string +} + +/** Driver whose execCapture replies are scripted by matching on the command text. */ +function scriptedDriver(script: Array<{ match: RegExp; result: Partial }>) { + const calls: Call[] = [] + const driver = { + execCapture: vi.fn(async (spec: ExecSpec, input?: string): Promise => { + calls.push({ spec, input }) + const joined = spec.cmd.join(' ') + const hit = script.find((s) => s.match.test(joined)) + return { code: 0, stdout: '', stderr: '', spawnError: false, ...(hit?.result ?? {}) } + }), + } as unknown as EngineDriver + return { driver, calls } +} + +const PUBKEY = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexample dcw-attach' +const HOST_PUB = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIhostkey root@container' +const HOST_PRIV = '-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----\n' + +let state: Awaited> + +beforeEach(async () => { + state = await useTempState('dcw-provision-') +}) + +afterEach(async () => { + await state.cleanup() + vi.restoreAllMocks() +}) + +describe('provisionContainerSsh', () => { + it('feeds the public key over stdin rather than interpolating it into a shell command', async () => { + const { driver, calls } = scriptedDriver([ + { match: /cat "\/home\/vscode\/\.ssh\/ssh_host_ed25519_key"/, result: { stdout: HOST_PRIV } }, + { match: /^cat \/home\/vscode/, result: { stdout: `${HOST_PUB}\n` } }, + ]) + + await provisionContainerSsh({ driver, container: 'cid-1', publicKey: PUBKEY, hostAlias: 'dcw-env1' }) + + const install = calls.find((c) => c.spec.cmd.join(' ').includes('authorized_keys'))! + expect(install.input).toBe(`${PUBKEY}\n`) + expect(install.spec.cmd.join(' ')).not.toContain(PUBKEY) + // The append is guarded by grep, so re-provisioning cannot duplicate the key. + expect(install.spec.cmd.join(' ')).toContain('grep -qxF "$key"') + expect(install.spec.user).toBe('vscode') + }) + + it('persists the container host key host-side so the known_hosts pin survives recreates', async () => { + const { driver } = scriptedDriver([ + { match: /cat "\/home\/vscode\/\.ssh\/ssh_host_ed25519_key"$/, result: { stdout: HOST_PRIV } }, + { match: /^cat \/home\/vscode/, result: { stdout: `${HOST_PUB}\n` } }, + ]) + + await provisionContainerSsh({ driver, container: 'cid-1', publicKey: PUBKEY, hostAlias: 'dcw-env1' }) + + expect(await fs.readFile(hostKeyPath('dcw-env1'), 'utf8')).toBe(HOST_PRIV) + const stat = await fs.stat(hostKeyPath('dcw-env1')) + expect(stat.mode & 0o777).toBe(0o600) + }) + + it('re-injects an already-persisted host key instead of generating a new one', async () => { + await fs.mkdir(state.dir, { recursive: true }) + const stored = hostKeyPath('dcw-env1') + await fs.mkdir(stored.slice(0, stored.lastIndexOf('/')), { recursive: true }) + await fs.writeFile(stored, HOST_PRIV) + + const { driver, calls } = scriptedDriver([{ match: /^cat \/home\/vscode/, result: { stdout: `${HOST_PUB}\n` } }]) + await provisionContainerSsh({ driver, container: 'cid-1', publicKey: PUBKEY, hostAlias: 'dcw-env1' }) + + const inject = calls[0]! + expect(inject.input).toBe(HOST_PRIV) + expect(inject.spec.cmd.join(' ')).toContain('ssh-keygen -y -f') + }) + + it('pins the host key in known_hosts in form', async () => { + const { driver } = scriptedDriver([ + { match: /cat "\/home\/vscode\/\.ssh\/ssh_host_ed25519_key"$/, result: { stdout: HOST_PRIV } }, + { match: /^cat \/home\/vscode/, result: { stdout: `${HOST_PUB}\n` } }, + ]) + await provisionContainerSsh({ driver, container: 'cid-1', publicKey: PUBKEY, hostAlias: 'dcw-env1' }) + + const [type, key] = HOST_PUB.split(' ') + expect(await fs.readFile(knownHostsPath(), 'utf8')).toBe(`dcw-env1 ${type} ${key}\n`) + }) + + it('replaces a stale pin for the same alias while keeping other hosts', async () => { + const file = knownHostsPath() + await fs.mkdir(file.slice(0, file.lastIndexOf('/')), { recursive: true }) + await fs.writeFile(file, 'other-host ssh-ed25519 KEEPME\ndcw-env1 ssh-ed25519 STALE\n') + + const { driver } = scriptedDriver([ + { match: /cat "\/home\/vscode\/\.ssh\/ssh_host_ed25519_key"$/, result: { stdout: HOST_PRIV } }, + { match: /^cat \/home\/vscode/, result: { stdout: `${HOST_PUB}\n` } }, + ]) + await provisionContainerSsh({ driver, container: 'cid-1', publicKey: PUBKEY, hostAlias: 'dcw-env1' }) + + const content = await fs.readFile(file, 'utf8') + expect(content).toContain('other-host ssh-ed25519 KEEPME') + expect(content).not.toContain('STALE') + expect(content.trim().split('\n')).toHaveLength(2) + }) + + it('raises a rebuild hint when the key install fails', async () => { + const { driver } = scriptedDriver([{ match: /authorized_keys/, result: { code: 1, stderr: 'sshd missing\n' } }]) + await expect( + provisionContainerSsh({ driver, container: 'cid-1', publicKey: PUBKEY, hostAlias: 'dcw-env1' }), + ).rejects.toThrow(/sshd missing.*dcw build/s) + }) + + it('reports the exit code when the failing install produced no stderr', async () => { + const { driver } = scriptedDriver([{ match: /authorized_keys/, result: { code: 3 } }]) + await expect( + provisionContainerSsh({ driver, container: 'cid-1', publicKey: PUBKEY, hostAlias: 'dcw-env1' }), + ).rejects.toThrow(/exit 3/) + }) + + it('is best-effort about persisting the host key: a failed capture is not fatal', async () => { + const { driver } = scriptedDriver([ + { match: /cat "\/home\/vscode\/\.ssh\/ssh_host_ed25519_key"$/, result: { code: 1 } }, + { match: /^cat \/home\/vscode/, result: { stdout: `${HOST_PUB}\n` } }, + ]) + await expect( + provisionContainerSsh({ driver, container: 'cid-1', publicKey: PUBKEY, hostAlias: 'dcw-env1' }), + ).resolves.toBeUndefined() + await expect(fs.access(hostKeyPath('dcw-env1'))).rejects.toThrow() + }) + + it('skips persisting when the container returned an empty host key', async () => { + const { driver } = scriptedDriver([ + { match: /cat "\/home\/vscode\/\.ssh\/ssh_host_ed25519_key"$/, result: { stdout: ' \n' } }, + { match: /^cat \/home\/vscode/, result: { stdout: `${HOST_PUB}\n` } }, + ]) + await provisionContainerSsh({ driver, container: 'cid-1', publicKey: PUBKEY, hostAlias: 'dcw-env1' }) + await expect(fs.access(hostKeyPath('dcw-env1'))).rejects.toThrow() + }) + + it('leaves known_hosts alone when the host pubkey cannot be read', async () => { + const { driver } = scriptedDriver([ + { match: /cat "\/home\/vscode\/\.ssh\/ssh_host_ed25519_key"$/, result: { stdout: HOST_PRIV } }, + { match: /^cat \/home\/vscode/, result: { code: 1 } }, + ]) + await provisionContainerSsh({ driver, container: 'cid-1', publicKey: PUBKEY, hostAlias: 'dcw-env1' }) + // ssh's accept-new will pin on first use instead. + await expect(fs.access(knownHostsPath())).rejects.toThrow() + }) + + it('ignores a malformed host pubkey rather than writing a broken pin', async () => { + const { driver } = scriptedDriver([ + { match: /cat "\/home\/vscode\/\.ssh\/ssh_host_ed25519_key"$/, result: { stdout: HOST_PRIV } }, + { match: /^cat \/home\/vscode/, result: { stdout: 'garbage\n' } }, + ]) + await provisionContainerSsh({ driver, container: 'cid-1', publicKey: PUBKEY, hostAlias: 'dcw-env1' }) + await expect(fs.access(knownHostsPath())).rejects.toThrow() + }) +}) + +describe('startSshDaemon', () => { + it('kills a prior daemon by pidfile — never by a -f pattern that would match itself', async () => { + const { driver, calls } = scriptedDriver([]) + await startSshDaemon(driver, 'cid-1') + + const cmd = calls[0]!.spec.cmd.join(' ') + expect(cmd).toContain('/home/vscode/.ssh/sshd.pid') + expect(cmd).not.toContain('pkill -f') + expect(cmd).toContain(`setsid /usr/sbin/sshd -D -f ${SSHD_CONFIG_PATH} -p 2222`) + expect(calls[0]!.spec.user).toBe('vscode') + }) + + it('raises when the daemon fails to start', async () => { + const { driver } = scriptedDriver([{ match: /sshd/, result: { code: 1, stderr: 'no sshd\n' } }]) + await expect(startSshDaemon(driver, 'cid-1')).rejects.toThrow(/Failed to start SSH daemon in 'cid-1': no sshd/) + }) + + it('reports the exit code when the failure produced no stderr', async () => { + const { driver } = scriptedDriver([{ match: /sshd/, result: { code: 9 } }]) + await expect(startSshDaemon(driver, 'cid-1')).rejects.toThrow(/exit 9/) + }) +}) diff --git a/packages/core/test/unit/store-nonzod-error.test.ts b/packages/core/test/unit/store-nonzod-error.test.ts new file mode 100644 index 0000000..979cfc0 --- /dev/null +++ b/packages/core/test/unit/store-nonzod-error.test.ts @@ -0,0 +1,59 @@ +import * as fs from 'node:fs/promises' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useTempState } from '../helpers/fixtures.js' + +// Model a migration step that fails with something other than a ZodError — what a +// future `case N: v1ToV2(raw)` would throw if it hit a bug of its own. +vi.mock('../../src/state/manifest.js', async (orig) => { + const actual = await orig() + return { + ...actual, + migrateManifest: (raw: unknown, from: number) => { + if (thrown) throw thrown + return actual.migrateManifest(raw, from) + }, + } +}) + +let thrown: unknown + +const { loadManifest, saveManifest } = await import('../../src/state/store.js') +const { manifest } = await import('../helpers/fixtures.js') + +let state: Awaited> + +beforeEach(async () => { + state = await useTempState('dcw-store-nonzod-') + thrown = undefined + await saveManifest(manifest({ name: 'env1' })) +}) + +afterEach(async () => { + await state.cleanup() + vi.clearAllMocks() +}) + +describe('loadManifest migration failures', () => { + it('reports the first zod issue when the manifest fails validation', async () => { + const { manifestPath } = await import('../../src/state/paths.js') + await fs.writeFile(manifestPath('env1'), JSON.stringify({ schemaVersion: 1, name: 'env1' })) + await expect(loadManifest('env1')).rejects.toMatchObject({ + code: 'E_VALIDATION', + message: expect.stringMatching(/^Manifest for 'env1' is invalid: .+\.$/), + }) + }) + + it('still produces a typed ValidationError when the failure carries no zod issues', async () => { + thrown = new Error('migration step exploded') + await expect(loadManifest('env1')).rejects.toMatchObject({ + code: 'E_VALIDATION', + message: "Manifest for 'env1' is invalid: unknown.", + }) + }) + + it('passes a ValidationError through untouched rather than re-wrapping it', async () => { + const { ValidationError } = await import('../../src/errors.js') + thrown = new ValidationError('already typed') + await expect(loadManifest('env1')).rejects.toMatchObject({ message: 'already typed' }) + }) +}) diff --git a/packages/core/test/unit/which-keys.test.ts b/packages/core/test/unit/which-keys.test.ts new file mode 100644 index 0000000..c9c1bfa --- /dev/null +++ b/packages/core/test/unit/which-keys.test.ts @@ -0,0 +1,103 @@ +import * as fs from 'node:fs/promises' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { CaptureResult } from '../../src/engine/exec.js' +import { useTempState } from '../helpers/fixtures.js' + +const calls: Array<{ bin: string; args: string[] }> = [] +let script: Array<{ match: RegExp; result: Partial }> = [] + +vi.mock('../../src/engine/exec.js', () => ({ + capture: async (bin: string, args: string[]): Promise => { + calls.push({ bin, args }) + const hit = script.find((s) => s.match.test(`${bin} ${args.join(' ')}`)) + return { code: 0, stdout: '', stderr: '', spawnError: false, ...(hit?.result ?? {}) } + }, + inherit: async () => 0, +})) + +const { isOnPath } = await import('../../src/util/which.js') +const { ensureKeypair } = await import('../../src/core/ssh/keys.js') + +beforeEach(() => { + calls.length = 0 + script = [] +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('isOnPath', () => { + it('accepts a hit from the POSIX `command -v` builtin', async () => { + script = [{ match: /^command -v git/, result: { stdout: '/usr/bin/git\n' } }] + expect(await isOnPath('git')).toBe(true) + expect(calls).toHaveLength(1) + }) + + it('falls back to `which` where `command` is not an executable binary', async () => { + // Most Linux distros ship no /usr/bin/command, so the first probe spawn-fails. + script = [ + { match: /^command -v git/, result: { spawnError: true, code: 127 } }, + { match: /^which git/, result: { stdout: '/usr/bin/git\n' } }, + ] + expect(await isOnPath('git')).toBe(true) + expect(calls.map((c) => c.bin)).toEqual(['command', 'which']) + }) + + it('falls back when `command` succeeds but prints nothing', async () => { + script = [{ match: /^which git/, result: { stdout: '/usr/bin/git\n' } }] + expect(await isOnPath('git')).toBe(true) + }) + + it('reports false when neither probe resolves the binary', async () => { + script = [ + { match: /^command -v nope/, result: { code: 1 } }, + { match: /^which nope/, result: { code: 1 } }, + ] + expect(await isOnPath('nope')).toBe(false) + }) + + it('reports false when `which` exits 0 but prints nothing', async () => { + script = [{ match: /^command -v nope/, result: { code: 1 } }] + expect(await isOnPath('nope')).toBe(false) + }) +}) + +describe('ensureKeypair failure handling', () => { + let state: Awaited> + + beforeEach(async () => { + state = await useTempState('dcw-keys-fail-') + }) + + afterEach(async () => { + await state.cleanup() + }) + + it('points at OpenSSH when ssh-keygen is not installed', async () => { + script = [{ match: /^ssh-keygen/, result: { spawnError: true, code: 127 } }] + await expect(ensureKeypair()).rejects.toThrow( + 'ssh-keygen not found on PATH; install OpenSSH to use `dcw attach`.', + ) + }) + + it('surfaces the ssh-keygen error when generation fails', async () => { + script = [{ match: /^ssh-keygen/, result: { code: 1, stderr: 'no space left on device\n' } }] + await expect(ensureKeypair()).rejects.toThrow('Failed to generate dcw SSH key: no space left on device.') + }) + + it('reports the exit code when ssh-keygen said nothing', async () => { + script = [{ match: /^ssh-keygen/, result: { code: 5 } }] + await expect(ensureKeypair()).rejects.toThrow('Failed to generate dcw SSH key: exit 5.') + }) + + it('regenerates when only one half of the pair survives', async () => { + const { sshDir } = await import('../../src/state/paths.js') + await fs.mkdir(sshDir(), { recursive: true }) + // A private key with no matching .pub must not be reused as-is. + await fs.writeFile(`${sshDir()}/id_ed25519`, 'stale') + script = [{ match: /^ssh-keygen/, result: { code: 1, stderr: 'boom' } }] + await expect(ensureKeypair()).rejects.toThrow('Failed to generate dcw SSH key') + expect(calls.some((c) => c.bin === 'ssh-keygen')).toBe(true) + }) +}) diff --git a/packages/core/test/wizard/app-navigation.test.tsx b/packages/core/test/wizard/app-navigation.test.tsx new file mode 100644 index 0000000..0b18e4a --- /dev/null +++ b/packages/core/test/wizard/app-navigation.test.tsx @@ -0,0 +1,278 @@ +import { render } from 'ink-testing-library' +import { createElement } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { App } from '../../src/wizard/App.js' +import { createDriver } from '../../src/engine/registry.js' +import type { EngineStatus } from '../../src/engine/resolver.js' +import type { EnvSpec } from '../../src/spec/env-spec.js' +import { BACKSPACE, CTRL_C, DOWN, ENTER, ESC, SPACE, UP, tick, type } from './keys.js' + +function engines(): EngineStatus[] { + return [ + { + name: 'docker', + displayName: 'Docker', + platform: { supported: true }, + detect: { available: true, version: 'Docker 99' }, + capabilities: createDriver('docker').capabilities, + recommended: true, + }, + { + name: 'podman', + displayName: 'Podman', + platform: { supported: true }, + detect: { available: false }, + capabilities: createDriver('podman').capabilities, + recommended: false, + }, + { + name: 'apple-container', + displayName: 'Apple Containers', + platform: { supported: false, reason: 'Apple Containers requires macOS.' }, + capabilities: createDriver('apple-container').capabilities, + recommended: false, + }, + ] +} + +function mount(initial: Record = { fallbackName: 'my-proj' }, list = engines()) { + const onComplete = vi.fn<(spec: EnvSpec) => void>() + const onCancel = vi.fn() + const r = render(createElement(App, { initial: initial as never, engines: list, onComplete, onCancel })) + return { ...r, onComplete, onCancel } +} + +/** Advance `steps` screens by accepting the current default on each. */ +async function accept(r: ReturnType, steps: number) { + await type(r.stdin, ...Array.from({ length: steps }, () => ENTER)) +} + +describe('wizard engine step', () => { + it('offers auto-detect first, naming the recommended engine', async () => { + const r = mount() + await tick() + const frame = r.lastFrame() ?? '' + expect(frame).toContain('Step 1/10 — Container engine') + expect(frame).toContain('Auto-detect') + expect(frame).toContain('Recommended: docker') + }) + + it('says so when nothing was detected', async () => { + const r = mount({ fallbackName: 'p' }, engines().map((e) => ({ ...e, recommended: false }))) + await tick() + expect(r.lastFrame()).toContain('No engine detected yet') + }) + + it("annotates each engine's availability and version", async () => { + const r = mount() + await type(r.stdin, DOWN) + expect(r.lastFrame()).toContain('available — Docker 99') + await type(r.stdin, DOWN) + expect(r.lastFrame()).toContain('not detected') + }) + + it('disables an engine that cannot run on this host', async () => { + const r = mount() + await tick() + expect(r.lastFrame()).toContain('Apple Containers') + expect(r.lastFrame()).toContain('(unavailable)') + }) + + it('cancels rather than navigating back from the first step', async () => { + const r = mount() + await type(r.stdin, ESC) + expect(r.onCancel).toHaveBeenCalledOnce() + }) + + it('records the chosen engine in the final spec', async () => { + const r = mount() + await type(r.stdin, DOWN, ENTER) + await accept(r, 9) + expect(r.onComplete).toHaveBeenCalledOnce() + expect(r.onComplete.mock.calls[0]![0].engine).toBe('docker') + }) +}) + +describe('wizard navigation', () => { + it('cancels on ctrl-c from any step', async () => { + const r = mount() + await type(r.stdin, ENTER, CTRL_C) + expect(r.onCancel).toHaveBeenCalled() + }) + + it('starts with a blank name when neither --name nor a directory name is supplied', async () => { + const r = mount({}) + await accept(r, 1) + expect(r.lastFrame()).toContain('Step 2/10 — Name') + expect(r.lastFrame()).toMatch(/Environment name:\s*▏/) + }) + + it('walks back to a previous step with Esc, keeping what was entered', async () => { + const r = mount() + await accept(r, 2) + expect(r.lastFrame()).toContain('Step 3/10 — Core languages') + await type(r.stdin, ESC) + expect(r.lastFrame()).toContain('Step 2/10 — Name') + expect(r.lastFrame()).toContain('my-proj') + }) + + it('keeps the previous name when the text field is cleared and submitted empty', async () => { + const r = mount({ name: 'seeded', fallbackName: 'fallback' }) + await accept(r, 1) + await type(r.stdin, ...Array.from({ length: 6 }, () => BACKSPACE), ENTER) + await accept(r, 8) + expect(r.onComplete.mock.calls[0]![0].name).toBe('seeded') + }) + + it('lets the name be retyped', async () => { + const r = mount() + await accept(r, 1) + await type(r.stdin, ...Array.from({ length: 8 }, () => BACKSPACE), 'r', 'e', 'n', 'a', 'm', 'e', 'd', ENTER) + await accept(r, 8) + expect(r.onComplete.mock.calls[0]![0].name).toBe('renamed') + }) + + it('seeds EVERY multi-select category from the flags, not just the first', async () => { + // All six steps render the same component type at the same position; without + // a per-step key React reuses one instance, so only the first category's + // seeded selection ever reached the spec. + const r = mount({ + fallbackName: 'p', + coreLanguages: ['rust'], + languages: ['solidity'], + frameworks: ['foundry'], + fuzzingAndTesting: ['echidna'], + securityTooling: ['slither'], + aiAgents: ['claude'], + }) + await accept(r, 10) + expect(r.onComplete.mock.calls[0]![0].selections).toEqual({ + coreLanguages: ['rust'], + languages: ['solidity'], + frameworks: ['foundry'], + fuzzingAndTesting: ['echidna'], + securityTooling: ['slither'], + aiAgents: ['claude'], + }) + }) + + it('starts each multi-select step at the top rather than inheriting the last cursor', async () => { + const r = mount() + await accept(r, 2) + await type(r.stdin, DOWN, ENTER) + expect(r.lastFrame()).toContain('Step 4/10 — Smart-contract languages') + expect(r.lastFrame()).toContain('❯ ◯ Solidity') + }) + + it('carries multi-select choices through to the spec', async () => { + const r = mount() + await accept(r, 2) + await type(r.stdin, SPACE, ENTER) + await accept(r, 7) + expect(r.onComplete.mock.calls[0]![0].selections.coreLanguages).toEqual(['rust']) + }) +}) + +describe('wizard hardening step', () => { + it('offers a custom mode that picks individual hardening options', async () => { + const r = mount() + await accept(r, 8) + expect(r.lastFrame()).toContain('Step 9/10 — Hardening') + + // "Custom…" sits directly above "None", i.e. two rows up from the top. + await type(r.stdin, UP, UP, ENTER) + expect(r.lastFrame()).toContain('Read-only root filesystem') + + await type(r.stdin, SPACE, ENTER, ENTER) + const spec = r.onComplete.mock.calls[0]![0] + expect(spec.hardening).toEqual(['readonly-os']) + expect(spec.profile).toBeUndefined() + }) + + it('can step back out of the custom list', async () => { + const r = mount() + await accept(r, 8) + await type(r.stdin, UP, UP, ENTER) + expect(r.lastFrame()).toContain('Read-only root filesystem') + await type(r.stdin, ESC) + expect(r.lastFrame()).toContain('Step 8/10 — AI coding agents') + }) + + it('pre-selects the profile passed in as a flag', async () => { + const r = mount({ fallbackName: 'p', profile: 'paranoid' }) + await accept(r, 8) + await type(r.stdin, ENTER, ENTER) + expect(r.onComplete.mock.calls[0]![0].profile).toBe('paranoid') + }) +}) + +describe('wizard review step', () => { + it('summarizes every chosen category', async () => { + const r = mount({ + fallbackName: 'p', + coreLanguages: ['rust'], + languages: ['solidity'], + frameworks: ['foundry'], + fuzzingAndTesting: ['echidna'], + securityTooling: ['slither'], + aiAgents: ['claude'], + }) + await accept(r, 9) + const frame = r.lastFrame() ?? '' + expect(frame).toContain('name: p') + expect(frame).toContain('core: rust') + expect(frame).toContain('languages: solidity') + expect(frame).toContain('frameworks: foundry') + expect(frame).toContain('fuzzing: echidna') + expect(frame).toContain('security: slither') + expect(frame).toContain('ai agents: claude') + expect(frame).toContain('enter to create · esc to go back') + }) + + it('shows which engine auto-detect would resolve to', async () => { + const r = mount() + await accept(r, 9) + expect(r.lastFrame()).toContain('engine: auto (→ docker)') + }) + + it("reports 'hardening: none' when the user opted out", async () => { + const r = mount() + await accept(r, 8) + await type(r.stdin, UP, ENTER) + expect(r.lastFrame()).toContain('hardening: none') + }) + + it('warns when the chosen engine cannot honor the requested hardening', async () => { + const supported = engines().map((e) => + e.name === 'apple-container' ? { ...e, platform: { supported: true }, detect: { available: true } } : e, + ) + const r = mount({ fallbackName: 'p', profile: 'airgapped' }, supported) + await type(r.stdin, DOWN, DOWN, DOWN, ENTER) + await accept(r, 8) + expect(r.lastFrame()).toContain('apple-container cannot honor') + }) + + it('refuses to build a spec it cannot validate, and offers a way back', async () => { + // A credential-bearing git remote is rejected by the EnvSpec schema, so the + // review step has nothing valid to confirm. + const r = mount({ fallbackName: 'p', gitUrl: 'https://user:tok@example.com/a/b.git' }) + await accept(r, 9) + expect(r.lastFrame()).toContain('Cannot build spec:') + expect(r.lastFrame()).toContain('esc to go back') + + await type(r.stdin, ENTER) + expect(r.onComplete).not.toHaveBeenCalled() + + await type(r.stdin, ESC) + expect(r.lastFrame()).toContain('Step 9/10 — Hardening') + }) + + it('confirms on Enter and goes back on Esc', async () => { + const r = mount() + await accept(r, 9) + await type(r.stdin, ESC) + expect(r.lastFrame()).toContain('Step 9/10 — Hardening') + await type(r.stdin, ENTER, ENTER) + expect(r.onComplete).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/core/test/wizard/components.test.tsx b/packages/core/test/wizard/components.test.tsx new file mode 100644 index 0000000..b119a43 --- /dev/null +++ b/packages/core/test/wizard/components.test.tsx @@ -0,0 +1,246 @@ +import { render } from 'ink-testing-library' +import { createElement } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { Banner } from '../../src/wizard/components/Banner.js' +import { MultiSelect } from '../../src/wizard/components/MultiSelect.js' +import { Select } from '../../src/wizard/components/Select.js' +import { TextInput } from '../../src/wizard/components/TextInput.js' +import { BACKSPACE, CTRL_A, DELETE, DOWN, ENTER, ESC, SPACE, UP, tick, type } from './keys.js' + +describe('Banner', () => { + it('renders a 1-based step counter and the step title', () => { + const { lastFrame } = render(createElement(Banner, { step: 2, total: 10, title: 'Frameworks' })) + expect(lastFrame()).toContain('dcw · container environment wizard') + expect(lastFrame()).toContain('Step 3/10 — Frameworks') + }) + + it('shows the counter alone when there is no title for the step', () => { + const { lastFrame } = render(createElement(Banner, { step: 0, total: 10 })) + expect(lastFrame()).toContain('Step 1/10') + expect(lastFrame()).not.toContain('—') + }) +}) + +describe('Select', () => { + const choices = [ + { label: 'Alpha', value: 'a', hint: 'first' }, + { label: 'Beta', value: 'b', hint: 'second', disabled: true }, + { label: 'Gamma', value: 'c', hint: 'third' }, + ] + + it('marks the cursor row, flags disabled rows, and shows the active hint', async () => { + const { lastFrame } = render(createElement(Select, { choices, onSubmit: vi.fn() })) + await tick() + const frame = lastFrame() ?? '' + expect(frame).toContain('❯ Alpha') + expect(frame).toContain('Beta (unavailable)') + expect(frame).toContain('first') + }) + + it('starts on the choice matching initialValue', async () => { + const { lastFrame } = render(createElement(Select, { choices, initialValue: 'c', onSubmit: vi.fn() })) + await tick() + expect(lastFrame()).toContain('❯ Gamma') + }) + + it('skips a disabled initialValue rather than starting on an unselectable row', async () => { + const { lastFrame } = render(createElement(Select, { choices, initialValue: 'b', onSubmit: vi.fn() })) + await tick() + expect(lastFrame()).toContain('❯ Alpha') + }) + + it('falls back to the first row when initialValue matches nothing', async () => { + const { lastFrame } = render(createElement(Select, { choices, initialValue: 'nope', onSubmit: vi.fn() })) + await tick() + expect(lastFrame()).toContain('❯ Alpha') + }) + + it('steps over disabled rows when moving down, and wraps around', async () => { + const { stdin, lastFrame } = render(createElement(Select, { choices, onSubmit: vi.fn() })) + await type(stdin, DOWN) + expect(lastFrame()).toContain('❯ Gamma') + await type(stdin, DOWN) + expect(lastFrame()).toContain('❯ Alpha') + }) + + it('steps over disabled rows when moving up, and wraps around', async () => { + const { stdin, lastFrame } = render(createElement(Select, { choices, onSubmit: vi.fn() })) + await type(stdin, UP) + expect(lastFrame()).toContain('❯ Gamma') + }) + + it('stays put when every other choice is disabled', async () => { + const onlyOne = [ + { label: 'Solo', value: 's' }, + { label: 'Off', value: 'o', disabled: true }, + ] + const { stdin, lastFrame } = render(createElement(Select, { choices: onlyOne, onSubmit: vi.fn() })) + await type(stdin, DOWN) + expect(lastFrame()).toContain('❯ Solo') + }) + + it('submits the highlighted value on Enter', async () => { + const onSubmit = vi.fn() + const { stdin } = render(createElement(Select, { choices, onSubmit })) + await type(stdin, DOWN, ENTER) + expect(onSubmit).toHaveBeenCalledWith('c') + }) + + it('never submits a disabled choice', async () => { + const onSubmit = vi.fn() + const disabledFirst = [ + { label: 'Off', value: 'o', disabled: true }, + { label: 'On', value: 'n' }, + ] + const { stdin } = render(createElement(Select, { choices: disabledFirst, initialValue: 'o', onSubmit })) + // initialIndex 0 is disabled, so the cursor has already moved past it. + await type(stdin, ENTER) + expect(onSubmit).toHaveBeenCalledWith('n') + }) + + it('calls onBack on Esc, and tolerates its absence', async () => { + const onBack = vi.fn() + const withBack = render(createElement(Select, { choices, onSubmit: vi.fn(), onBack })) + await type(withBack.stdin, ESC) + expect(onBack).toHaveBeenCalledOnce() + + const withoutBack = render(createElement(Select, { choices, onSubmit: vi.fn() })) + await type(withoutBack.stdin, ESC) + expect(withoutBack.lastFrame()).toContain('Alpha') + }) + + it('has nowhere to move, and submits nothing, when every choice is disabled', async () => { + const onSubmit = vi.fn() + const allOff = [ + { label: 'Off1', value: 'a', disabled: true }, + { label: 'Off2', value: 'b', disabled: true }, + ] + const { stdin, lastFrame } = render(createElement(Select, { choices: allOff, onSubmit })) + await type(stdin, DOWN, UP, ENTER) + expect(lastFrame()).toContain('❯ Off1') + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('omits the hint block for a choice that has none', async () => { + const { lastFrame } = render(createElement(Select, { choices: [{ label: 'Bare', value: 'x' }], onSubmit: vi.fn() })) + await tick() + expect(lastFrame()?.trim()).toBe('❯ Bare') + }) +}) + +describe('MultiSelect', () => { + const items = [ + { label: 'Rust', value: 'rust', hint: 'cargo' }, + { label: 'Python', value: 'python', hint: 'pip' }, + { label: 'Go', value: 'go' }, + ] + + it('renders checkboxes, pre-checking the initial selection, and shows the active hint', async () => { + const { lastFrame } = render(createElement(MultiSelect, { items, initial: ['python'], onSubmit: vi.fn() })) + await tick() + const frame = lastFrame() ?? '' + expect(frame).toContain('❯ ◯ Rust') + expect(frame).toContain('◉ Python') + expect(frame).toContain('cargo') + expect(frame).toContain('space toggle · enter confirm · esc back') + }) + + it('toggles the highlighted item on space, on and off again', async () => { + const { stdin, lastFrame } = render(createElement(MultiSelect, { items, onSubmit: vi.fn() })) + await type(stdin, SPACE) + expect(lastFrame()).toContain('❯ ◉ Rust') + await type(stdin, SPACE) + expect(lastFrame()).toContain('❯ ◯ Rust') + }) + + it('moves the cursor with the arrows and wraps in both directions', async () => { + const { stdin, lastFrame } = render(createElement(MultiSelect, { items, onSubmit: vi.fn() })) + await type(stdin, UP) + expect(lastFrame()).toContain('❯ ◯ Go') + await type(stdin, DOWN) + expect(lastFrame()).toContain('❯ ◯ Rust') + }) + + it('submits the selection in catalog order, not toggle order', async () => { + const onSubmit = vi.fn() + const { stdin } = render(createElement(MultiSelect, { items, onSubmit })) + await type(stdin, DOWN, DOWN, SPACE, UP, UP, SPACE, ENTER) + expect(onSubmit).toHaveBeenCalledWith(['rust', 'go']) + }) + + it('submits an empty array when nothing is selected', async () => { + const onSubmit = vi.fn() + const { stdin } = render(createElement(MultiSelect, { items, onSubmit })) + await type(stdin, ENTER) + expect(onSubmit).toHaveBeenCalledWith([]) + }) + + it('calls onBack on Esc, and tolerates its absence', async () => { + const onBack = vi.fn() + const withBack = render(createElement(MultiSelect, { items, onSubmit: vi.fn(), onBack })) + await type(withBack.stdin, ESC) + expect(onBack).toHaveBeenCalledOnce() + + const withoutBack = render(createElement(MultiSelect, { items, onSubmit: vi.fn() })) + await type(withoutBack.stdin, ESC) + expect(withoutBack.lastFrame()).toContain('Rust') + }) + + it('renders an empty-state line and submits nothing when there are no items', async () => { + const onSubmit = vi.fn() + const { stdin, lastFrame } = render(createElement(MultiSelect, { items: [], onSubmit })) + await tick() + expect(lastFrame()).toContain('(no options)') + await type(stdin, SPACE, ENTER) + expect(onSubmit).toHaveBeenCalledWith([]) + }) +}) + +describe('TextInput', () => { + it('renders the label, the current value and a cursor', async () => { + const { lastFrame } = render( + createElement(TextInput, { label: 'Environment name:', initial: 'demo', onSubmit: vi.fn() }), + ) + await tick() + expect(lastFrame()).toContain('Environment name:') + expect(lastFrame()).toContain('demo') + }) + + it('appends typed characters', async () => { + const { stdin, lastFrame } = render(createElement(TextInput, { label: 'n:', onSubmit: vi.fn() })) + await type(stdin, 'a', 'b', 'c') + expect(lastFrame()).toContain('abc') + }) + + it('removes the last character on backspace and on delete', async () => { + const { stdin, lastFrame } = render(createElement(TextInput, { label: 'n:', initial: 'abcd', onSubmit: vi.fn() })) + await type(stdin, BACKSPACE) + expect(lastFrame()).toContain('abc') + await type(stdin, DELETE) + expect(lastFrame()).toContain('ab') + }) + + it('ignores control chords rather than inserting them', async () => { + const { stdin, lastFrame } = render(createElement(TextInput, { label: 'n:', initial: 'ab', onSubmit: vi.fn() })) + await type(stdin, CTRL_A) + expect(lastFrame()).toContain('ab') + }) + + it('submits the trimmed value on Enter', async () => { + const onSubmit = vi.fn() + const { stdin } = render(createElement(TextInput, { label: 'n:', initial: ' demo ', onSubmit })) + await type(stdin, ENTER) + expect(onSubmit).toHaveBeenCalledWith('demo') + }) + + it('calls onBack on Esc, and tolerates its absence', async () => { + const onBack = vi.fn() + const withBack = render(createElement(TextInput, { label: 'n:', onSubmit: vi.fn(), onBack })) + await type(withBack.stdin, ESC) + expect(onBack).toHaveBeenCalledOnce() + + const withoutBack = render(createElement(TextInput, { label: 'n:', initial: 'x', onSubmit: vi.fn() })) + await type(withoutBack.stdin, ESC) + expect(withoutBack.lastFrame()).toContain('x') + }) +}) diff --git a/packages/core/test/wizard/keys.ts b/packages/core/test/wizard/keys.ts new file mode 100644 index 0000000..bc69b5c --- /dev/null +++ b/packages/core/test/wizard/keys.ts @@ -0,0 +1,31 @@ +/** + * Terminal key sequences the ink test harness writes to stdin. + * Built from char codes so the control bytes stay legible in source. + */ +const ch = (code: number) => String.fromCharCode(code) + +export const ENTER = ch(13) +export const ESC = ch(27) +export const UP = `${ESC}[A` +export const DOWN = `${ESC}[B` +export const BACKSPACE = ch(8) +export const DELETE = ch(127) +export const CTRL_A = ch(1) +export const CTRL_C = ch(3) +export const SPACE = ' ' + +/** Let ink flush a render between keystrokes. */ +export const tick = (): Promise => new Promise((r) => setTimeout(r, 20)) + +/** + * Write each key in turn, letting the component re-render between them. + * The leading tick matters: ink discards input written before the first render, + * so without it the opening keystroke of every test would be silently dropped. + */ +export async function type(stdin: { write: (s: string) => void }, ...keys: string[]): Promise { + await tick() + for (const key of keys) { + stdin.write(key) + await tick() + } +} diff --git a/packages/core/test/wizard/render-hook.ts b/packages/core/test/wizard/render-hook.ts new file mode 100644 index 0000000..07ce737 --- /dev/null +++ b/packages/core/test/wizard/render-hook.ts @@ -0,0 +1,24 @@ +import { render } from 'ink-testing-library' +import { createElement } from 'react' + +/** + * Minimal hook harness: render a component that only calls the hook and records + * its return value, so hooks can be driven without a UI around them. + */ +export function renderHook(hook: () => T): { current: T; act: (fn: () => void) => Promise } { + const box = { + current: undefined as unknown as T, + act: async (_fn: () => void): Promise => {}, + } + function Probe() { + box.current = hook() + return null + } + render(createElement(Probe)) + box.act = async (fn: () => void) => { + fn() + // Let React flush the state update into a re-render. + await new Promise((r) => setTimeout(r, 20)) + } + return box as { current: T; act: (fn: () => void) => Promise } +} diff --git a/packages/core/test/wizard/run.test.tsx b/packages/core/test/wizard/run.test.tsx new file mode 100644 index 0000000..b7cdcc7 --- /dev/null +++ b/packages/core/test/wizard/run.test.tsx @@ -0,0 +1,115 @@ +import type { ReactElement } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createDriver } from '../../src/engine/registry.js' +import type { EngineStatus } from '../../src/engine/resolver.js' +import type { EnvSpec } from '../../src/spec/env-spec.js' + +interface AppProps { + initial: Record + engines: EngineStatus[] + onComplete: (spec: EnvSpec) => void + onCancel: () => void +} + +let rendered: { element: ReactElement; options: Record } | undefined +const unmount = vi.fn() +const detectHost = vi.fn(async () => ({ os: 'linux' as const, arch: 'x64' as const })) +const surveyEngines = vi.fn(async (): Promise => []) + +vi.mock('ink', () => ({ + render: (element: ReactElement, options: Record) => { + rendered = { element, options } + return { unmount } + }, +})) +vi.mock('../../src/engine/host.js', async (orig) => { + const actual = await orig() + return { ...actual, detectHost: () => detectHost() } +}) +vi.mock('../../src/engine/resolver.js', async (orig) => { + const actual = await orig() + return { ...actual, surveyEngines: () => surveyEngines() } +}) + +const { mountWizard } = await import('../../src/wizard/run.js') +const { App } = await import('../../src/wizard/App.js') + +const dockerStatus: EngineStatus = { + name: 'docker', + displayName: 'Docker', + platform: { supported: true }, + detect: { available: true }, + capabilities: createDriver('docker').capabilities, + recommended: true, +} + +const props = () => rendered!.element.props + +/** mountWizard awaits host detection + the engine survey before it renders. */ +const untilRendered = () => new Promise((r) => setTimeout(r, 10)) + +beforeEach(() => { + rendered = undefined + unmount.mockClear() + surveyEngines.mockResolvedValue([dockerStatus]) +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +describe('mountWizard', () => { + it('renders App seeded with the flag input and the surveyed engines', async () => { + const promise = mountWizard({ initial: { name: 'seed', fallbackName: 'cwd' } }) + await untilRendered() + expect(rendered!.element.type).toBe(App) + expect(props().initial).toEqual({ name: 'seed', fallbackName: 'cwd' }) + expect(props().engines).toEqual([dockerStatus]) + // ink's own ctrl-C handling is disabled; App cancels explicitly instead. + expect(rendered!.options).toEqual({ exitOnCtrlC: false }) + + props().onCancel() + await promise + }) + + it('resolves with the authored spec and unmounts the UI', async () => { + const promise = mountWizard({ initial: {} }) + await untilRendered() + const spec = { name: 'authored', engine: 'auto', selections: {}, hardening: [], ssh: true } as EnvSpec + + props().onComplete(spec) + + await expect(promise).resolves.toBe(spec) + expect(unmount).toHaveBeenCalledOnce() + }) + + it('resolves with null when the wizard is cancelled', async () => { + const promise = mountWizard({ initial: {} }) + await untilRendered() + props().onCancel() + await expect(promise).resolves.toBeNull() + expect(unmount).toHaveBeenCalledOnce() + }) + + it('settles once: a late callback cannot re-resolve or double-unmount', async () => { + const promise = mountWizard({ initial: {} }) + await untilRendered() + const spec = { name: 'first', engine: 'auto', selections: {}, hardening: [], ssh: true } as EnvSpec + + props().onComplete(spec) + props().onCancel() + props().onComplete({ ...spec, name: 'second' }) + + await expect(promise).resolves.toBe(spec) + expect(unmount).toHaveBeenCalledOnce() + }) + + it('detects the host before surveying engines', async () => { + const promise = mountWizard({ initial: {} }) + await untilRendered() + expect(detectHost).toHaveBeenCalledOnce() + expect(surveyEngines).toHaveBeenCalledOnce() + props().onCancel() + await promise + }) +}) diff --git a/packages/core/test/wizard/use-step-nav.test.ts b/packages/core/test/wizard/use-step-nav.test.ts new file mode 100644 index 0000000..ea6d6ed --- /dev/null +++ b/packages/core/test/wizard/use-step-nav.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { renderHook } from './render-hook.js' +import { useStepNav } from '../../src/wizard/hooks/useStepNav.js' + +describe('useStepNav', () => { + it('starts at the first step', () => { + const nav = renderHook(() => useStepNav(3)) + expect(nav.current.index).toBe(0) + expect(nav.current.atStart).toBe(true) + }) + + it('advances forward and reports that it is no longer at the start', async () => { + const nav = renderHook(() => useStepNav(3)) + await nav.act(() => nav.current.next()) + expect(nav.current.index).toBe(1) + expect(nav.current.atStart).toBe(false) + }) + + it('clamps at the last step instead of running past the end', async () => { + const nav = renderHook(() => useStepNav(2)) + await nav.act(() => nav.current.next()) + await nav.act(() => nav.current.next()) + expect(nav.current.index).toBe(1) + }) + + it('clamps at the first step instead of going negative', async () => { + const nav = renderHook(() => useStepNav(3)) + await nav.act(() => nav.current.back()) + expect(nav.current.index).toBe(0) + }) + + it('walks back down the steps it walked up', async () => { + const nav = renderHook(() => useStepNav(4)) + await nav.act(() => nav.current.next()) + await nav.act(() => nav.current.next()) + await nav.act(() => nav.current.back()) + expect(nav.current.index).toBe(1) + }) +}) diff --git a/packages/core/tsconfig.test.json b/packages/core/tsconfig.test.json new file mode 100644 index 0000000..21534f1 --- /dev/null +++ b/packages/core/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + // Type-checks the test suite alongside src (the build config compiles src only). + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + // The test suite reaches into bin/*.js, which ships untyped. + "allowJs": true, + "checkJs": false + }, + "include": ["src", "test", "bin"] +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index 6715530..5f6ad04 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -4,5 +4,22 @@ export default defineConfig({ test: { include: ['test/**/*.test.ts', 'test/**/*.test.tsx'], environment: 'node', + coverage: { + provider: 'v8', + // Measure the shipped source, not the tests or the build output. + include: ['src/**'], + reporter: ['text', 'html', 'lcov'], + // Every statement, branch, function and line of src/ is covered, and the + // bars are set there so a newly uncovered path fails the run instead of + // slipping in. No per-line ignore pragmas are used: the handful of arms + // that no input could reach were removed from the source rather than + // excused here. + thresholds: { + statements: 100, + functions: 100, + lines: 100, + branches: 100, + }, + }, }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cc8e85..a65d568 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: '@types/react': specifier: ^18.3.12 version: 18.3.31 + '@vitest/coverage-v8': + specifier: ^2.1.9 + version: 2.1.9(vitest@2.1.9(@types/node@18.19.122)) ink-testing-library: specifier: ^4.0.0 version: 4.0.0(@types/react@18.3.31) @@ -64,6 +67,10 @@ packages: resolution: {integrity: sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==} engines: {node: '>=14.13.1'} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -219,6 +226,26 @@ packages: resolution: {integrity: sha512-6Ed0kmC1NMbuFTEgNmamAUU1h5gShgxL1hBVLbEzUa3trX5aJBz1vU4bXaBTvOYUAnOHtiy1Ml4AMStd6hJnFA==} engines: {node: '>=18.0.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -667,9 +694,27 @@ packages: '@types/node': optional: true + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} engines: {node: ^22.20 || ^24.12 || >=25} @@ -692,6 +737,10 @@ packages: resolution: {integrity: sha512-YDlr//SHmC80eZrt+0wNFWSo1cOSU60RoWdhSkAoPB3pUGPSNHZDquXDpo7KniinzYPsj1rfetCYk7UVXwYu7A==} engines: {node: '>=18.0.0'} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@pnpm/config.env-replace@1.1.0': resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} @@ -1076,6 +1125,15 @@ packages: '@types/wrap-ansi@3.0.0': resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} + '@vitest/coverage-v8@2.1.9': + resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==} + peerDependencies: + '@vitest/browser': 2.1.9 + vitest: 2.1.9 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@2.1.9': resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} @@ -1150,12 +1208,20 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + bowser@2.12.0: resolution: {integrity: sha512-HcOcTudTeEWgbHh0Y1Tyb6fdeR71m4b/QACf0D4KswGTsNeIJQmg38mRENZPAYPZvGFN3fk3604XbQEPdxXdKg==} brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1245,6 +1311,10 @@ packages: resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1280,6 +1350,9 @@ packages: dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ejs@3.1.10: resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} engines: {node: '>=0.10.0'} @@ -1291,6 +1364,9 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -1358,6 +1434,10 @@ packages: find-yarn-workspace-root@2.0.0: resolution: {integrity: sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + form-data-encoder@2.1.4: resolution: {integrity: sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==} engines: {node: '>= 14.17'} @@ -1393,6 +1473,11 @@ packages: github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + got@13.0.0: resolution: {integrity: sha512-XfBk1CxOOScDcMr9O1yKkNaQyy865NbYs+F7dr4H0LZMVgCj2Le59k6PqbNHoL5ToeaEQUYh6c6yMfVcc6SJxA==} engines: {node: '>=16'} @@ -1414,6 +1499,9 @@ packages: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -1507,6 +1595,28 @@ packages: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jake@10.9.4: resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} engines: {node: '>=10'} @@ -1554,6 +1664,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -1570,6 +1687,10 @@ packages: resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + minimatch@5.1.6: resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} engines: {node: '>=10'} @@ -1578,6 +1699,10 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1618,6 +1743,9 @@ packages: resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==} engines: {node: '>=12.20'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + param-case@3.0.4: resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} @@ -1635,6 +1763,14 @@ packages: path-case@3.0.4: resolution: {integrity: sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==} + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -1715,6 +1851,14 @@ packages: sentence-case@3.0.4: resolution: {integrity: sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1773,6 +1917,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -1788,10 +1936,18 @@ packages: strnum@2.1.1: resolution: {integrity: sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + supports-color@8.1.1: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + tiny-jsonc@1.0.2: resolution: {integrity: sha512-f5QDAfLq6zIVSyCZQZhhyl0QS6MvAyTxgz4X4x3+EoCktNWEYJ6PeoEA97fyb98njpBNNi88ybpD7m+BDFXaCw==} @@ -1863,6 +2019,7 @@ packages: uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true validate-npm-package-license@3.0.4: @@ -1933,6 +2090,11 @@ packages: jsdom: optional: true + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -1957,6 +2119,10 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} @@ -1995,6 +2161,11 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 4.0.0 + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -2508,6 +2679,21 @@ snapshots: '@smithy/types': 4.3.2 tslib: 2.8.1 + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@0.2.3': {} + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -2819,8 +3005,31 @@ snapshots: optionalDependencies: '@types/node': 18.19.122 + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.6': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true @@ -2869,6 +3078,9 @@ snapshots: transitivePeerDependencies: - supports-color + '@pkgjs/parseargs@0.11.0': + optional: true + '@pnpm/config.env-replace@1.1.0': {} '@pnpm/network.ca-file@1.0.2': @@ -3325,6 +3537,24 @@ snapshots: '@types/wrap-ansi@3.0.0': {} + '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@18.19.122))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.4.1(supports-color@8.1.1) + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 1.2.0 + vitest: 2.1.9(@types/node@18.19.122) + transitivePeerDependencies: + - supports-color + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -3397,12 +3627,18 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + bowser@2.12.0: {} brace-expansion@2.0.2: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -3505,6 +3741,12 @@ snapshots: convert-to-spaces@2.0.1: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + csstype@3.2.3: {} debug@4.4.1(supports-color@8.1.1): @@ -3530,6 +3772,8 @@ snapshots: no-case: 3.0.4 tslib: 2.8.1 + eastasianwidth@0.2.0: {} + ejs@3.1.10: dependencies: jake: 10.9.4 @@ -3538,6 +3782,8 @@ snapshots: emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + environment@1.1.0: {} error-ex@1.3.2: @@ -3639,6 +3885,11 @@ snapshots: dependencies: micromatch: 4.0.8 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + form-data-encoder@2.1.4: {} fs-extra@8.1.0: @@ -3662,6 +3913,15 @@ snapshots: github-slugger@2.0.0: {} + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + got@13.0.0: dependencies: '@sindresorhus/is': 5.6.0 @@ -3691,6 +3951,8 @@ snapshots: dependencies: lru-cache: 10.4.3 + html-escaper@2.0.2: {} + http-cache-semantics@4.2.0: {} http-call@5.3.0: @@ -3782,6 +4044,35 @@ snapshots: dependencies: is-docker: 2.2.1 + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.1(supports-color@8.1.1) + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jake@10.9.4: dependencies: async: 3.2.6 @@ -3824,6 +4115,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.2 + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -3835,6 +4136,10 @@ snapshots: mimic-response@4.0.0: {} + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + minimatch@5.1.6: dependencies: brace-expansion: 2.0.2 @@ -3843,6 +4148,8 @@ snapshots: dependencies: brace-expansion: 2.0.2 + minipass@7.1.3: {} + ms@2.1.3: {} mute-stream@1.0.0: {} @@ -3901,6 +4208,8 @@ snapshots: p-cancelable@3.0.0: {} + package-json-from-dist@1.0.1: {} + param-case@3.0.4: dependencies: dot-case: 3.0.4 @@ -3923,6 +4232,13 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + pathe@1.1.2: {} pathval@2.0.1: {} @@ -4018,6 +4334,12 @@ snapshots: tslib: 2.8.1 upper-case-first: 2.0.2 + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -4082,6 +4404,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -4098,10 +4426,20 @@ snapshots: strnum@2.1.1: {} + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + supports-color@8.1.1: dependencies: has-flag: 4.0.0 + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 10.5.0 + minimatch: 10.2.6 + tiny-jsonc@1.0.2: {} tinybench@2.9.0: {} @@ -4226,6 +4564,10 @@ snapshots: - supports-color - terser + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -4253,6 +4595,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 From 4402476b56254a5c1094563c9acd8182022a9ac3 Mon Sep 17 00:00:00 2001 From: d4rm5 Date: Thu, 20 Aug 2026 17:53:12 -0300 Subject: [PATCH 7/7] chore(deps): bump @oclif/core to 4.14.0, clearing every production advisory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm audit --prod` reported 12 advisories (10 high, 2 moderate), all transitive through @oclif/core 4.5.2: minimatch, brace-expansion and picomatch (via tinyglobby). 4.14.0 depends on minimatch ^10.2.5 and newer tinyglobby, so the whole set clears — production audit is now clean. The bump stayed inside the declared ^4 range; pnpm raised the floor to ^4.14.0 to match. Everything still passes on the new major-minor: 733 tests at 100% coverage, typecheck, build, the e2e lifecycle, `oclif manifest`, and a by-hand check that the --json error envelope contract is intact (E_USAGE/exit 2 for an unknown flag and a bad --engine value, E_NOT_FOUND/exit 8 for a missing environment). One test needed adjusting, in the test rather than the source. oclif's own fallback handler changed how it serializes an ExitError under --json: 4.5 logged the raw error object, 4.14 logs a structured form via toErrorJson. The assertion had pinned that shape, which is oclif's implementation detail — what dcw actually promises is that it does not claim an ExitError as its own (no E_* envelope, no second exit), so the test now asserts that instead. Remaining advisories are dev-only (the `oclif` CLI's AWS SDK deps, and vitest/vite/esbuild). None are reachable at runtime and none ship: the published tarball is bin/, dist/, skill/, the manifest and LICENSE. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013LFtDpUHewj56KruJAWbFz --- packages/core/package.json | 2 +- packages/core/test/unit/base-command.test.ts | 10 +- pnpm-lock.yaml | 178 ++++++++++++------- 3 files changed, 124 insertions(+), 66 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 122f4da..a8728b6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -52,7 +52,7 @@ "postpack": "rm -f oclif.manifest.json" }, "dependencies": { - "@oclif/core": "^4.5.2", + "@oclif/core": "^4.14.0", "ink": "^5.1.0", "react": "^18.3.1", "zod": "^3.23.8", diff --git a/packages/core/test/unit/base-command.test.ts b/packages/core/test/unit/base-command.test.ts index 9d3cd36..2b2fc7f 100644 --- a/packages/core/test/unit/base-command.test.ts +++ b/packages/core/test/unit/base-command.test.ts @@ -107,9 +107,15 @@ describe('BaseCommand.catch', () => { // ExitError is not an error: turning it into an envelope would corrupt the // exit code of every streaming command. const res = await catchWith(new Errors.ExitError(0), true) - // It is handed to oclif untouched rather than rewritten into a dcw envelope. - expect(res.jsonLogs).toEqual([{ error: expect.any(Errors.ExitError) }]) + + // What matters is that dcw does NOT claim it: no E_* envelope of ours, and no + // second exit on top of the one already in flight. How oclif's own fallback + // handler then serializes the ExitError is its business — 4.5 logged the raw + // error object, 4.14 logs a structured form — so assert our contract, not theirs. expect(res.exitCode).toBeUndefined() + expect(res.jsonLogs).toHaveLength(1) + const envelope = (res.jsonLogs[0] as { error: { code?: string } }).error + expect(envelope.code).not.toMatch(/^E_/) }) it('wraps an untyped failure in the same envelope, never a raw Node error object', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a65d568..b49b9eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: packages/core: dependencies: '@oclif/core': - specifier: ^4.5.2 - version: 4.5.2 + specifier: ^4.14.0 + version: 4.14.0 ink: specifier: ^5.1.0 version: 5.2.1(@types/react@18.3.31)(react@18.3.1) @@ -721,8 +721,8 @@ packages: cpu: [x64] os: [linux] - '@oclif/core@4.5.2': - resolution: {integrity: sha512-eQcKyrEcDYeZJKu4vUWiu0ii/1Gfev6GF4FsLSgNez5/+aQyAUCjg3ZWlurf491WiYZTXCWyKAxyPWk8DKv2MA==} + '@oclif/core@4.14.0': + resolution: {integrity: sha512-QCJIZoVJxV7jywgVAUlsQwl3dr3Sa21kfi5z9KrYolbexWJUtFSEtMoNiBWojD05mYycLnB50gp/VxlGdaOCRA==} engines: {node: '>=18.0.0'} '@oclif/plugin-help@6.2.32': @@ -1215,8 +1215,8 @@ packages: bowser@2.12.0: resolution: {integrity: sha512-HcOcTudTeEWgbHh0Y1Tyb6fdeR71m4b/QACf0D4KswGTsNeIJQmg38mRENZPAYPZvGFN3fk3604XbQEPdxXdKg==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} brace-expansion@5.0.9: resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} @@ -1327,6 +1327,15 @@ packages: supports-color: optional: true + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -1358,6 +1367,11 @@ packages: engines: {node: '>=0.10.0'} hasBin: true + ejs@6.0.1: + resolution: {integrity: sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==} + engines: {node: '>=0.12.18'} + hasBin: true + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -1416,8 +1430,9 @@ packages: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} engines: {node: '>= 4.9.1'} - fdir@6.4.6: - resolution: {integrity: sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} peerDependencies: picomatch: ^3 || ^4 peerDependenciesMeta: @@ -1553,9 +1568,9 @@ packages: is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - is-docker@2.2.1: - resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} - engines: {node: '>=8'} + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true is-fullwidth-code-point@3.0.0: @@ -1575,6 +1590,11 @@ packages: engines: {node: '>=18'} hasBin: true + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -1591,9 +1611,9 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} - is-wsl@2.2.0: - resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} - engines: {node: '>=8'} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1691,12 +1711,12 @@ packages: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} - minimatch@5.1.6: - resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} engines: {node: '>=10'} - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} minipass@7.1.3: @@ -1781,18 +1801,22 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} postcss@8.5.26: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} @@ -1848,6 +1872,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + sentence-case@3.0.4: resolution: {integrity: sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==} @@ -1957,8 +1986,8 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - tinyglobby@0.2.14: - resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} tinypool@1.1.1: @@ -2139,6 +2168,10 @@ packages: utf-8-validate: optional: true + wsl-utils@0.4.0: + resolution: {integrity: sha512-9YmF+2sFEd+T7TkwlmE337F0IVzfDvDknhtpBQxxXzEOfgPphGlFYpyx0cTuCIFj8/p+sqwBYAeGxOMNSzPPDA==} + engines: {node: '>=20'} + yoctocolors-cjs@2.1.2: resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} engines: {node: '>=18'} @@ -3033,35 +3066,35 @@ snapshots: '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true - '@oclif/core@4.5.2': + '@oclif/core@4.14.0': dependencies: ansi-escapes: 4.3.2 ansis: 3.17.0 clean-stack: 3.0.1 cli-spinners: 2.9.2 - debug: 4.4.1(supports-color@8.1.1) - ejs: 3.1.10 + debug: 4.4.3(supports-color@8.1.1) + ejs: 6.0.1 get-package-type: 0.1.0 indent-string: 4.0.0 - is-wsl: 2.2.0 lilconfig: 3.1.3 - minimatch: 9.0.5 - semver: 7.7.2 + minimatch: 10.2.6 + semver: 7.8.5 string-width: 4.2.3 supports-color: 8.1.1 - tinyglobby: 0.2.14 + tinyglobby: 0.2.17 widest-line: 3.1.0 wordwrap: 1.0.0 wrap-ansi: 7.0.0 + wsl-utils: 0.4.0 '@oclif/plugin-help@6.2.32': dependencies: - '@oclif/core': 4.5.2 + '@oclif/core': 4.14.0 '@oclif/plugin-not-found@3.2.65(@types/node@18.19.122)': dependencies: '@inquirer/prompts': 7.8.3(@types/node@18.19.122) - '@oclif/core': 4.5.2 + '@oclif/core': 4.14.0 ansis: 3.17.0 fast-levenshtein: 3.0.0 transitivePeerDependencies: @@ -3069,9 +3102,9 @@ snapshots: '@oclif/plugin-warn-if-update-available@3.1.46': dependencies: - '@oclif/core': 4.5.2 + '@oclif/core': 4.14.0 ansis: 3.17.0 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 http-call: 5.3.0 lodash: 4.17.21 registry-auth-token: 5.1.0 @@ -3541,7 +3574,7 @@ snapshots: dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 0.2.3 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-lib-source-maps: 5.0.6 @@ -3631,7 +3664,7 @@ snapshots: bowser@2.12.0: {} - brace-expansion@2.0.2: + brace-expansion@2.1.4: dependencies: balanced-match: 1.0.2 @@ -3749,7 +3782,11 @@ snapshots: csstype@3.2.3: {} - debug@4.4.1(supports-color@8.1.1): + debug@4.4.1: + dependencies: + ms: 2.1.3 + + debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 optionalDependencies: @@ -3778,6 +3815,8 @@ snapshots: dependencies: jake: 10.9.4 + ejs@6.0.1: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -3869,13 +3908,13 @@ snapshots: fastest-levenshtein@1.0.16: {} - fdir@6.4.6(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.5 filelist@1.0.4: dependencies: - minimatch: 5.1.6 + minimatch: 5.1.9 fill-range@7.1.1: dependencies: @@ -3917,7 +3956,7 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 3.4.3 - minimatch: 9.0.5 + minimatch: 9.0.9 minipass: 7.1.3 package-json-from-dist: 1.0.1 path-scurry: 1.11.1 @@ -3958,7 +3997,7 @@ snapshots: http-call@5.3.0: dependencies: content-type: 1.0.5 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 is-retry-allowed: 1.2.0 is-stream: 2.0.1 parse-json: 4.0.0 @@ -4020,7 +4059,7 @@ snapshots: is-arrayish@0.2.1: {} - is-docker@2.2.1: {} + is-docker@3.0.0: {} is-fullwidth-code-point@3.0.0: {} @@ -4032,6 +4071,10 @@ snapshots: is-in-ci@1.0.0: {} + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + is-number@7.0.0: {} is-plain-obj@4.1.0: {} @@ -4040,9 +4083,9 @@ snapshots: is-stream@2.0.1: {} - is-wsl@2.2.0: + is-wsl@3.1.1: dependencies: - is-docker: 2.2.1 + is-inside-container: 1.0.0 isexe@2.0.0: {} @@ -4057,7 +4100,7 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: '@jridgewell/trace-mapping': 0.3.31 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -4123,12 +4166,12 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.2 + semver: 7.8.5 micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 mimic-fn@2.1.0: {} @@ -4140,13 +4183,13 @@ snapshots: dependencies: brace-expansion: 5.0.9 - minimatch@5.1.6: + minimatch@5.1.9: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.1.4 - minimatch@9.0.5: + minimatch@9.0.9: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.1.4 minipass@7.1.3: {} @@ -4178,14 +4221,14 @@ snapshots: '@inquirer/confirm': 3.2.0 '@inquirer/input': 2.3.0 '@inquirer/select': 2.5.0 - '@oclif/core': 4.5.2 + '@oclif/core': 4.14.0 '@oclif/plugin-help': 6.2.32 '@oclif/plugin-not-found': 3.2.65(@types/node@18.19.122) '@oclif/plugin-warn-if-update-available': 3.1.46 ansis: 3.17.0 async-retry: 1.3.3 change-case: 4.1.2 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 ejs: 3.1.10 find-yarn-workspace-root: 2.0.0 fs-extra: 8.1.0 @@ -4245,9 +4288,9 @@ snapshots: picocolors@1.1.1: {} - picomatch@2.3.1: {} + picomatch@2.3.2: {} - picomatch@4.0.3: {} + picomatch@4.0.5: {} postcss@8.5.26: dependencies: @@ -4255,6 +4298,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + powershell-utils@0.1.0: {} + proto-list@1.2.4: {} quick-lru@5.1.1: {} @@ -4328,6 +4373,8 @@ snapshots: semver@7.7.2: {} + semver@7.8.5: {} + sentence-case@3.0.4: dependencies: no-case: 3.0.4 @@ -4372,7 +4419,7 @@ snapshots: is-plain-obj: 4.1.0 semver: 7.7.2 sort-object-keys: 1.1.3 - tinyglobby: 0.2.14 + tinyglobby: 0.2.17 source-map-js@1.2.1: {} @@ -4446,10 +4493,10 @@ snapshots: tinyexec@0.3.2: {} - tinyglobby@0.2.14: + tinyglobby@0.2.17: dependencies: - fdir: 6.4.6(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinypool@1.1.1: {} @@ -4505,7 +4552,7 @@ snapshots: vite-node@2.1.9(@types/node@18.19.122): dependencies: cac: 6.7.14 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@18.19.122) @@ -4539,7 +4586,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.1(supports-color@8.1.1) + debug: 4.4.1 expect-type: 1.4.0 magic-string: 0.30.21 pathe: 1.1.2 @@ -4609,6 +4656,11 @@ snapshots: ws@8.21.3: {} + wsl-utils@0.4.0: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + yoctocolors-cjs@2.1.2: {} yoga-layout@3.2.1: {}