impel-cli 0.20.38 → 0.20.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -203,9 +203,10 @@ Claude selection.
203
203
  Codex loads a generated, tenant-bound profile with the ultra-fast Codex Spark
204
204
  model at low reasoning for the thin adapter turns, a read-only local sandbox,
205
205
  the exact fixed MCP binding, and only that binding's answer/run, resume, and
206
- recovery tools. It omits unrelated local repository, app, collaboration,
207
- permission, environment, and skill-catalog prompt context; the selected Eve
208
- agent still performs the substantive work. Model, profile,
206
+ recovery tools. It disables host plugin and remote-plugin loading and omits
207
+ unrelated local repository, app, collaboration, permission, environment, and
208
+ skill-catalog prompt context; the selected Eve agent still performs the
209
+ substantive work. Model, profile,
209
210
  developer-instruction, MCP, approval, sandbox, and feature overrides are
210
211
  rejected for its managed path. Ordinary `impel claude`, user-authored Claude
211
212
  agent, and `impel codex` passthrough are unchanged.
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.20.40 — Field-report fixes: launcher drift, sync ownership, Codex capability, one-run update
4
+
5
+ - Survives Claude auto-update drift: `impel claude` resolves the exact reviewed
6
+ release from the installer's side-by-side layout instead of exiting 127 when
7
+ the vendor updater moves the shared symlink; setup/update detection shares
8
+ the same resolution, ending the reinstall loop.
9
+ - Makes `impel agents sync` ownership symmetric across Claude and Codex with
10
+ digest-proven reclaim: manifest scope drift self-heals, operator-edited
11
+ files still refuse fail-closed, non-owning manifests can no longer delete
12
+ another tenant's files, and shared roots converge on tenant switch.
13
+ - Unstrands answer-mode adapters: retrying the answer call is permitted only
14
+ before a continuation exists, continuation ids tolerate case drift, and the
15
+ invalid-handle error now says what to pass instead.
16
+ - Restores Codex capability in the managed app and CLI: the generated desktop
17
+ profile enables the Code Mode host (pinned Codex fails closed on
18
+ code_mode_only models without it, hiding the 5.6 family with its max/ultra
19
+ efforts and Fast tier), the CLI model catalog is served from the same
20
+ registry as the desktop (max/ultra/Fast restored), and a dropped
21
+ code_mode_host key now self-heals as drift on current installs.
22
+ - Sizes the Codex verbatim-relay parent wait from the child's attachment
23
+ window so visible working time is no longer truncated, and direct answers
24
+ emit progress notifications.
25
+ - Converges `impel update` in one run: npm-prefix skew hands convergence to
26
+ the verified freshly installed entrypoint instead of aborting after a
27
+ partial update, and install-recovery re-probes live state so completed
28
+ steps are never re-issued.
29
+
30
+ ## 0.20.39 — Isolate fixed Codex agent adapters
31
+
32
+ - Disables local and remote plugin loading, locally built plugin sharing, and
33
+ recommended-plugin discovery only in qualified fixed-binding Codex agent
34
+ profiles while preserving the direct Code Mode namespace and ordinary
35
+ `impel codex` plugin behavior.
36
+ - Advances the managed-agent manifest and generated-config versions so existing
37
+ managed installs receive the isolated adapter profile on update.
38
+
3
39
  ## 0.20.38 — Restore managed Claude agent permissions
4
40
 
5
41
  - Verifies an exact synchronized Claude agent and its generated file digest
package/bin/impel.js CHANGED
@@ -13,6 +13,15 @@ if (process.platform === "win32" && process.env.IMPEL_SKIP_ENTRYPOINT_REFRESH !=
13
13
  }
14
14
 
15
15
  main(process.argv.slice(2)).catch((err) => {
16
- console.error(`impel: ${err?.stack || err?.message || err}`);
16
+ // Intentional user-facing errors are thrown as bare `Error` with guidance in
17
+ // the message ("not authenticated; run `impel setup`…"); printing their stack
18
+ // buries the guidance in noise on end-user machines. Programming errors
19
+ // (TypeError, system errors with a code) keep the full stack, and
20
+ // IMPEL_DEBUG=1 restores it for everything.
21
+ const expected = err instanceof Error && err.constructor === Error && !err.code;
22
+ const detail = expected && process.env.IMPEL_DEBUG !== "1"
23
+ ? err.message
24
+ : (err?.stack || err?.message || err);
25
+ console.error(`impel: ${detail}`);
17
26
  process.exitCode = 1;
18
27
  });
@@ -1,6 +1,6 @@
1
1
  # Native-agent host capability matrix
2
2
 
3
- Validated on 2026-08-08 with isolated temporary homes and tenant profiles. The
3
+ Validated on 2026-08-09 with isolated temporary homes and tenant profiles. The
4
4
  matrix is intentionally pinned: a client upgrade must be re-qualified before it
5
5
  is treated as an eager host.
6
6
 
@@ -50,8 +50,9 @@ is treated as an eager host.
50
50
  `impel_agent` is not equivalent and does not qualify as direct exposure.
51
51
  - Generated Codex fixed-binding adapters use the ultra-fast Codex Spark model
52
52
  at low reasoning for the thin tool-selection and terminal-relay turns, with
53
- unrelated local host and skill-catalog context disabled. The selected Eve
54
- agent, not this wrapper model, still owns the substantive task.
53
+ unrelated local host, plugin, remote-plugin, and skill-catalog context
54
+ disabled. The selected Eve agent, not this wrapper model, still owns the
55
+ substantive task.
55
56
 
56
57
  ## Requalification
57
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.20.38",
3
+ "version": "0.20.40",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,56 @@
1
+ // Capture the released-state fixture corpus (docs/testing-protocol.md §5
2
+ // P0-2, Law 3: fixtures are captured from released artifacts, never
3
+ // fabricated from HEAD after the fact).
4
+ //
5
+ // Run this AT RELEASE TIME, on the exact tree being tagged, so
6
+ // test/fixtures/released/<version>/ honestly represents what shipped:
7
+ //
8
+ // node scripts/capture-release-fixtures.mjs [--force]
9
+ //
10
+ // It regenerates the managed on-disk artifacts deterministically and offline
11
+ // (see scripts/regen-managed-artifacts.mjs: fixed inputs, temp HOMEs, fake
12
+ // impel_pat_TEST credential, double-generation byte comparison) into
13
+ // test/fixtures/released/<package.json version>/. An existing version
14
+ // directory is never overwritten unless --force is passed.
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ import { REPOSITORY_ROOT, regenerateManagedArtifacts } from "./regen-managed-artifacts.mjs";
21
+
22
+ export const RELEASED_FIXTURES_ROOT = path.join(REPOSITORY_ROOT, "test", "fixtures", "released");
23
+
24
+ export async function captureReleaseFixtures({ force = false } = {}) {
25
+ const packageJson = JSON.parse(fs.readFileSync(path.join(REPOSITORY_ROOT, "package.json"), "utf8"));
26
+ const version = packageJson.version;
27
+ if (typeof version !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(version)) {
28
+ throw new Error(`package.json version "${version}" is not a valid release version`);
29
+ }
30
+ const outputDir = path.join(RELEASED_FIXTURES_ROOT, version);
31
+ if (fs.existsSync(outputDir) && !force) {
32
+ throw new Error(
33
+ `${outputDir} already exists. A released fixture is immutable once captured; `
34
+ + "pass --force only to re-capture a fixture that never shipped.",
35
+ );
36
+ }
37
+ const metadata = await regenerateManagedArtifacts(outputDir, { force });
38
+ return { version, outputDir, metadata };
39
+ }
40
+
41
+ const isMain = process.argv[1]
42
+ && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
43
+
44
+ if (isMain) {
45
+ const { version, outputDir, metadata } = await captureReleaseFixtures({
46
+ force: process.argv.includes("--force"),
47
+ });
48
+ console.log(
49
+ `captured released-state fixtures for v${version}: ${metadata.artifacts.length} artifacts `
50
+ + `(configVersion ${metadata.configVersion}, ${metadata.bundleFingerprint})`,
51
+ );
52
+ console.log(` ${outputDir}`);
53
+ for (const excluded of metadata.excluded) {
54
+ console.log(` excluded: ${excluded.path}`);
55
+ }
56
+ }
@@ -0,0 +1,51 @@
1
+ # Clean-machine protocol (P0-4)
2
+
3
+ Runs the CLI the way a brand-new customer machine does — no git, no winget, no
4
+ dev tooling, stock PATH — because that machine state is structurally
5
+ unreachable from CI runners, and it is where an entire class of first-run
6
+ failures shipped (see `docs/testing-protocol.md`, RC2).
7
+
8
+ ## Windows leg
9
+
10
+ ```sh
11
+ AWS_PROFILE=useimpel scripts/clean-machine/run-clean-windows.sh
12
+ ```
13
+
14
+ Launches a disposable, self-terminating Windows Server EC2 instance (no
15
+ inbound access, no SSH key, S3-scoped instance role) that:
16
+
17
+ 1. asserts the machine is actually clean (git absent; winget state recorded);
18
+ 2. installs Node headless from the official MSI;
19
+ 3. global-installs the packed tarball with the bundled npm and asserts the
20
+ install is a real directory (the npm prepare-script symlink class);
21
+ 4. runs `impel --version`, `impel doctor`, and non-interactive `impel setup`
22
+ with hang guards, asserting actionable single-line failures — never a
23
+ stack trace, never a hang, never a silent success;
24
+ 5. runs the full ungated test suite on the git-less machine — the leg that
25
+ catches tests silently depending on runner tooling;
26
+ 6. uploads `results.json` + full logs to S3 and terminates itself.
27
+
28
+ The runner exits 0 only when every post-condition holds and the suite has no
29
+ failing test. First run of this harness found 18 environment-sensitive tests
30
+ and two CLI output defects; expect it to keep earning its keep.
31
+
32
+ ## When to run
33
+
34
+ - Before any stable release that touches install/update/setup paths
35
+ (protocol §4.3), and after changing anything under `src/installRecovery/`,
36
+ `src/selfInvocation.js`, or the skills/git provisioning chain.
37
+ - Cost: one t3.large for ~15 minutes (well under $0.10) plus a few MB of S3.
38
+
39
+ ## Known limitations
40
+
41
+ - Windows **Server** AMIs never ship winget, so the winget install path can't
42
+ be exercised here (EC2 offers no consumer Windows 11 images). That leg stays
43
+ covered by `test/launch.test.js` (cmd-escaping contract) and the MSIX/Store
44
+ contract workflow. The results record winget absence so the gap is visible.
45
+ - The instance runs as SYSTEM at first boot, not as an interactive standard
46
+ user; per-user ACL behavior is covered by the `windows-standard-user-contract`
47
+ CI job instead.
48
+ - macOS leg: EC2 Mac requires a 24-hour dedicated-host allocation, so the
49
+ macOS clean-machine equivalent (running vendor apps, name-colliding consumer
50
+ apps, stale Homebrew binaries) is not automated here yet; the launch-smoke
51
+ CI job covers the bundle/egress side on ephemeral macOS runners.
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env bash
2
+ # Clean-machine protocol (docs/testing-protocol.md P0-4), Windows leg runner.
3
+ #
4
+ # Launches a disposable, self-terminating Windows Server EC2 instance that has
5
+ # no git and no dev tooling, installs Node headless, global-installs the packed
6
+ # CLI tarball, asserts post-conditions (version/doctor/setup actionable, real
7
+ # install dir), runs the full ungated test suite, and uploads results to S3.
8
+ # The instance needs no inbound access and no SSH key; results come back via a
9
+ # bucket-scoped instance role.
10
+ #
11
+ # Usage:
12
+ # AWS_PROFILE=useimpel scripts/clean-machine/run-clean-windows.sh
13
+ #
14
+ # Tunables (env): AWS_PROFILE, CLEAN_MACHINE_REGION (default eu-west-2),
15
+ # CLEAN_MACHINE_BUCKET, CLEAN_MACHINE_INSTANCE_TYPE (default t3.large),
16
+ # CLEAN_MACHINE_NODE_VERSION (default v24.19.0).
17
+ #
18
+ # One-time account setup (idempotent, created automatically if missing):
19
+ # bucket, IAM role/instance-profile `clean-machine-test-role` (S3 Get/Put on
20
+ # the bucket only), and a no-inbound security group.
21
+ set -euo pipefail
22
+
23
+ REGION="${CLEAN_MACHINE_REGION:-eu-west-2}"
24
+ ACCOUNT="$(aws sts get-caller-identity --query Account --output text)"
25
+ BUCKET="${CLEAN_MACHINE_BUCKET:-impel-clean-machine-tests-${ACCOUNT}}"
26
+ INSTANCE_TYPE="${CLEAN_MACHINE_INSTANCE_TYPE:-t3.large}"
27
+ NODE_VERSION="${CLEAN_MACHINE_NODE_VERSION:-v24.19.0}"
28
+ ROLE="clean-machine-test-role"
29
+ SG_NAME="impel-clean-machine-no-inbound"
30
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
31
+ REPO_ROOT="$(cd "$HERE/../.." && pwd)"
32
+ WORK="$(mktemp -d)"
33
+ trap 'rm -rf "$WORK"' EXIT
34
+ export AWS_DEFAULT_REGION="$REGION"
35
+
36
+ echo "== packing CLI + source from $REPO_ROOT"
37
+ (cd "$REPO_ROOT" && pnpm pack --pack-destination "$WORK" >/dev/null)
38
+ TARBALL="$(basename "$(find "$WORK" -maxdepth 1 -name 'impel-cli-*.tgz' -print -quit)")"
39
+ test -n "$TARBALL"
40
+ (cd "$REPO_ROOT" && git archive --format=tar.gz -o "$WORK/impel-cli-source.tgz" HEAD)
41
+
42
+ echo "== ensuring bucket/role/security group"
43
+ aws s3api head-bucket --bucket "$BUCKET" 2>/dev/null \
44
+ || aws s3api create-bucket --bucket "$BUCKET" --create-bucket-configuration "LocationConstraint=$REGION" >/dev/null
45
+ aws iam get-role --role-name "$ROLE" >/dev/null 2>&1 || {
46
+ aws iam create-role --role-name "$ROLE" --assume-role-policy-document \
47
+ '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}' >/dev/null
48
+ aws iam create-instance-profile --instance-profile-name "$ROLE" >/dev/null
49
+ aws iam add-role-to-instance-profile --instance-profile-name "$ROLE" --role-name "$ROLE"
50
+ sleep 10 # instance-profile eventual consistency
51
+ }
52
+ aws iam put-role-policy --role-name "$ROLE" --policy-name s3-scratch --policy-document \
53
+ "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"s3:GetObject\",\"s3:PutObject\"],\"Resource\":\"arn:aws:s3:::$BUCKET/*\"}]}"
54
+ VPC="$(aws ec2 describe-vpcs --filters Name=is-default,Values=true --query 'Vpcs[0].VpcId' --output text)"
55
+ SG="$(aws ec2 describe-security-groups --filters "Name=group-name,Values=$SG_NAME" --query 'SecurityGroups[0].GroupId' --output text 2>/dev/null)"
56
+ if [ "$SG" = "None" ] || [ -z "$SG" ]; then
57
+ SG="$(aws ec2 create-security-group --group-name "$SG_NAME" --description "No-inbound SG for impel clean-machine tests" --vpc-id "$VPC" --query GroupId --output text)"
58
+ fi
59
+ SUBNET="$(aws ec2 describe-subnets --filters Name=default-for-az,Values=true --query 'Subnets[0].SubnetId' --output text)"
60
+ AMI="$(aws ssm get-parameter --name /aws/service/ami-windows-latest/Windows_Server-2022-English-Full-Base --query 'Parameter.Value' --output text)"
61
+
62
+ echo "== uploading artifacts to s3://$BUCKET"
63
+ aws s3 rm "s3://$BUCKET/results" --recursive --only-show-errors 2>/dev/null || true
64
+ aws s3 cp "$WORK/$TARBALL" "s3://$BUCKET/$TARBALL" --only-show-errors
65
+ aws s3 cp "$WORK/impel-cli-source.tgz" "s3://$BUCKET/impel-cli-source.tgz" --only-show-errors
66
+
67
+ sed -e "s|__BUCKET__|$BUCKET|g" -e "s|__REGION__|$REGION|g" \
68
+ -e "s|__CLI_TARBALL__|$TARBALL|g" -e "s|__NODE_VERSION__|$NODE_VERSION|g" \
69
+ "$HERE/userdata.ps1.template" > "$WORK/userdata.ps1"
70
+
71
+ echo "== launching $INSTANCE_TYPE ($AMI) in $SUBNET"
72
+ INSTANCE="$(aws ec2 run-instances --image-id "$AMI" --instance-type "$INSTANCE_TYPE" \
73
+ --iam-instance-profile "Name=$ROLE" --instance-initiated-shutdown-behavior terminate \
74
+ --user-data "file://$WORK/userdata.ps1" --security-group-ids "$SG" --subnet-id "$SUBNET" \
75
+ --metadata-options HttpTokens=required \
76
+ --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=impel-clean-machine-test},{Key=purpose,Value=impel-cli-testing-protocol}]' \
77
+ --query 'Instances[0].InstanceId' --output text)"
78
+ echo "instance: $INSTANCE (self-terminates when done)"
79
+
80
+ echo "== waiting for results (boot + install + suite; typically 8-20 min)"
81
+ for i in $(seq 1 45); do
82
+ if aws s3api head-object --bucket "$BUCKET" --key results/results.json >/dev/null 2>&1; then
83
+ aws s3 cp "s3://$BUCKET/results/results.json" "$WORK/results.json" --only-show-errors
84
+ echo "== results after ~${i} min:"
85
+ node -e '
86
+ const r = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8").replace(/^/, ""));
87
+ const s = r.steps;
88
+ const fail = [];
89
+ if (s.preconditions.gitPresent) fail.push("machine not clean: git present");
90
+ if (s.nodeMsiExit !== 0) fail.push(`node MSI exit ${s.nodeMsiExit}`);
91
+ if (!s.impelCmdExists) fail.push("impel.cmd missing after global install");
92
+ if (!s.installIsRealDir) fail.push("global install is a reparse point (npm symlink class)");
93
+ if (s.version && !/^\d+\.\d+\.\d+/.test((s.version.stdoutHead || "").trim())) fail.push("impel --version malformed");
94
+ for (const k of ["doctor", "setupNoAuth"]) {
95
+ const st = s[k]; if (!st) continue;
96
+ if (st.timedOut) fail.push(`${k} hung`);
97
+ if (/\n\s+at /.test(st.stderrHead || "")) fail.push(`${k} printed a stack trace`);
98
+ }
99
+ const suite = s.testSuite || {};
100
+ const m = (suite.stdoutHead || "").match(/# fail (\d+)/) || (suite.stdoutHead || "").match(/✖/);
101
+ if (suite.timedOut) fail.push("test suite hung");
102
+ if ((suite.stdoutHead || "").includes("✖")) fail.push("test suite has failing tests (see results/node-test.out.txt)");
103
+ console.log(JSON.stringify({ preconditions: s.preconditions, node: (s.nodeVersion||"").trim(), npm: (s.npmVersion||"").trim(), verdict: fail.length ? "FAIL" : "PASS", failures: fail }, null, 2));
104
+ process.exitCode = fail.length ? 1 : 0;
105
+ ' "$WORK/results.json" || { echo "full logs: s3://$BUCKET/results/"; exit 1; }
106
+ echo "full logs: s3://$BUCKET/results/"
107
+ exit 0
108
+ fi
109
+ STATE="$(aws ec2 describe-instances --instance-ids "$INSTANCE" --query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null || echo unknown)"
110
+ if [ "$STATE" = "terminated" ]; then echo "instance terminated without uploading results"; exit 1; fi
111
+ sleep 60
112
+ done
113
+ echo "timed out waiting for results; terminating $INSTANCE"
114
+ aws ec2 terminate-instances --instance-ids "$INSTANCE" >/dev/null
115
+ exit 1
@@ -0,0 +1,102 @@
1
+ <powershell>
2
+ # Clean-machine protocol (docs/testing-protocol.md P0-4), Windows leg.
3
+ # Rendered by run-clean-windows.sh: __BUCKET__, __REGION__, __CLI_TARBALL__,
4
+ # __NODE_VERSION__ are substituted before launch. Runs at first boot as SYSTEM
5
+ # on a fresh Windows Server AMI (no git, no dev tooling), self-terminates.
6
+ $ErrorActionPreference = 'Continue'
7
+ $root = 'C:\clean-machine'
8
+ New-Item -ItemType Directory -Force -Path $root | Out-Null
9
+ Start-Transcript -Path "$root\transcript.txt" -Force
10
+ $bucket = '__BUCKET__'
11
+ $region = '__REGION__'
12
+ $results = [ordered]@{ startedAt = (Get-Date).ToUniversalTime().ToString('o'); steps = [ordered]@{} }
13
+
14
+ function Run-Step {
15
+ param([string]$name, [string]$file, [string[]]$argList, [int]$timeoutSec = 180, [string]$stdin = $null)
16
+ $out = "$root\$name.out.txt"; $err = "$root\$name.err.txt"
17
+ $procArgs = @{ FilePath = $file; ArgumentList = $argList; RedirectStandardOutput = $out; RedirectStandardError = $err; NoNewWindow = $true; PassThru = $true }
18
+ if ($null -ne $stdin) { $inFile = "$root\$name.in.txt"; Set-Content -Path $inFile -Value $stdin; $procArgs.RedirectStandardInput = $inFile }
19
+ $step = [ordered]@{ timedOut = $false; exitCode = $null; stdoutHead = ''; stderrHead = '' }
20
+ try {
21
+ $p = Start-Process @procArgs
22
+ if (-not $p.WaitForExit($timeoutSec * 1000)) {
23
+ $step.timedOut = $true
24
+ Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
25
+ Start-Sleep -Seconds 2
26
+ }
27
+ $step.exitCode = $p.ExitCode
28
+ } catch { $step.error = "$_" }
29
+ foreach ($pair in @(@('stdoutHead', $out, 3000), @('stderrHead', $err, 2000))) {
30
+ $key = $pair[0]; $file2 = $pair[1]; $keep = $pair[2]
31
+ if (Test-Path $file2) {
32
+ $text = Get-Content $file2 -Raw -ErrorAction SilentlyContinue
33
+ if ($null -eq $text) { $text = '' }
34
+ if ($text.Length -gt (2 * $keep)) { $text = $text.Substring(0, $keep) + "`n...TRUNCATED...`n" + $text.Substring($text.Length - $keep) }
35
+ $step[$key] = $text
36
+ }
37
+ }
38
+ return $step
39
+ }
40
+
41
+ # 1. Clean-machine preconditions: git must be absent; record winget state.
42
+ # (Windows Server AMIs never ship winget — the winget-specific install leg
43
+ # needs consumer Windows and stays covered by launch.test.js + the MSIX
44
+ # contract job; record the state so the gap is visible, never silent.)
45
+ $results.steps.preconditions = [ordered]@{
46
+ gitPresent = [bool](Get-Command git -ErrorAction SilentlyContinue)
47
+ wingetPresent = [bool](Get-Command winget -ErrorAction SilentlyContinue)
48
+ osCaption = (Get-CimInstance Win32_OperatingSystem).Caption
49
+ }
50
+
51
+ # 2. Install Node headless from the official MSI.
52
+ Invoke-WebRequest -Uri 'https://nodejs.org/dist/__NODE_VERSION__/node-__NODE_VERSION__-x64.msi' -OutFile "$root\node.msi" -UseBasicParsing
53
+ $msi = Start-Process msiexec.exe -ArgumentList '/i', "$root\node.msi", '/qn', '/norestart' -Wait -PassThru
54
+ $results.steps.nodeMsiExit = $msi.ExitCode
55
+ $env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path', 'User')
56
+ $nodeDir = 'C:\Program Files\nodejs'
57
+ $results.steps.nodeVersion = & "$nodeDir\node.exe" --version 2>&1 | Out-String
58
+ $results.steps.npmVersion = & "$nodeDir\npm.cmd" --version 2>&1 | Out-String
59
+
60
+ # 3. Fetch artifacts from S3 via the instance role.
61
+ Read-S3Object -BucketName $bucket -Key '__CLI_TARBALL__' -File "$root\impel-cli.tgz" -Region $region | Out-Null
62
+ Read-S3Object -BucketName $bucket -Key 'impel-cli-source.tgz' -File "$root\source.tgz" -Region $region | Out-Null
63
+
64
+ # 4. npm global tarball install on a git-less machine (install-channel class).
65
+ $results.steps.npmInstall = Run-Step 'npm-install' "$nodeDir\npm.cmd" @('install', '--global', "$root\impel-cli.tgz") 300
66
+ $npmPrefix = (& "$nodeDir\npm.cmd" prefix -g 2>$null | Out-String).Trim()
67
+ $results.steps.npmPrefix = $npmPrefix
68
+ $impelCmd = Join-Path $npmPrefix 'impel.cmd'
69
+ $results.steps.impelCmdExists = Test-Path $impelCmd
70
+ $pkgDir = Join-Path $npmPrefix 'node_modules\impel-cli'
71
+ $item = Get-Item $pkgDir -ErrorAction SilentlyContinue
72
+ $results.steps.installIsRealDir = ($null -ne $item) -and (-not ($item.Attributes -band [IO.FileAttributes]::ReparsePoint))
73
+
74
+ # 5. Post-conditions, not exit codes: version, doctor, setup must be
75
+ # actionable, never hang, never dump a stack trace at the user.
76
+ if ($results.steps.impelCmdExists) {
77
+ $results.steps.version = Run-Step 'impel-version' $impelCmd @('--version') 60
78
+ $results.steps.doctor = Run-Step 'impel-doctor' $impelCmd @('doctor') 180
79
+ $results.steps.setupNoAuth = Run-Step 'impel-setup' $impelCmd @('setup') 180 -stdin ''
80
+ }
81
+ $results.steps.nodeProcsAfter = @(Get-Process -Name node -ErrorAction SilentlyContinue).Count
82
+ $results.steps.cmdProcsAfter = @(Get-Process -Name cmd -ErrorAction SilentlyContinue).Count
83
+
84
+ # 6. The full ungated test suite on a machine with no git and no dev tooling —
85
+ # the leg that catches environment-sensitive tests CI can never see.
86
+ New-Item -ItemType Directory -Force -Path "$root\src" | Out-Null
87
+ tar.exe -xzf "$root\source.tgz" -C "$root\src"
88
+ Push-Location "$root\src"
89
+ $env:HOME = "$root\home"; New-Item -ItemType Directory -Force -Path $env:HOME | Out-Null
90
+ $results.steps.testSuite = Run-Step 'node-test' "$nodeDir\node.exe" @('--test') 1500
91
+ Pop-Location
92
+
93
+ $results.finishedAt = (Get-Date).ToUniversalTime().ToString('o')
94
+ $results | ConvertTo-Json -Depth 6 | Set-Content -Path "$root\results.json" -Encoding UTF8
95
+ Stop-Transcript
96
+ Write-S3Object -BucketName $bucket -Key 'results/results.json' -File "$root\results.json" -Region $region
97
+ Write-S3Object -BucketName $bucket -Key 'results/transcript.txt' -File "$root\transcript.txt" -Region $region
98
+ foreach ($f in Get-ChildItem "$root\*.out.txt", "$root\*.err.txt" -ErrorAction SilentlyContinue) {
99
+ Write-S3Object -BucketName $bucket -Key "results/$($f.Name)" -File $f.FullName -Region $region
100
+ }
101
+ Stop-Computer -Force
102
+ </powershell>