oh-my-opencode 4.9.2 → 4.10.0
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/.agents/skills/opencode-qa/scripts/lib/common.sh +39 -1
- package/.agents/skills/tech-debt-audit/SKILL.md +277 -0
- package/.agents/skills/work-with-pr-workspace/iteration-1/eval-5/with_skill/outputs/execution-plan.md +1 -1
- package/.opencode/skills/work-with-pr-workspace/iteration-1/eval-5/with_skill/outputs/execution-plan.md +1 -1
- package/bin/platform.js +5 -0
- package/bin/platform.test.ts +56 -0
- package/dist/agents/atlas/agent.d.ts +4 -3
- package/dist/agents/gpt-apply-patch-guard.d.ts +2 -2
- package/dist/agents/hephaestus/agent.d.ts +5 -0
- package/dist/agents/hephaestus/index.d.ts +1 -1
- package/dist/agents/metis.d.ts +1 -0
- package/dist/agents/prometheus/system-prompt.d.ts +1 -1
- package/dist/agents/sisyphus/kimi-k2-7.d.ts +17 -0
- package/dist/agents/sisyphus-junior/agent.d.ts +1 -1
- package/dist/agents/sisyphus-junior/kimi-k2-7.d.ts +11 -0
- package/dist/agents/types.d.ts +2 -2
- package/dist/cli/doctor/checks/codex-components.d.ts +13 -0
- package/dist/cli/doctor/checks/tui-plugin-config.d.ts +1 -0
- package/dist/cli/doctor/constants.d.ts +1 -1
- package/dist/cli/index.js +929 -291
- package/dist/cli/install-codex/codex-cleanup.d.ts +4 -0
- package/dist/cli/install-codex/install-codex-test-fixtures.d.ts +34 -0
- package/dist/cli/install-codex/link-cached-plugin-agents.d.ts +4 -0
- package/dist/cli/model-fallback.d.ts +1 -0
- package/dist/cli/provider-availability.d.ts +2 -0
- package/dist/cli-node/index.js +929 -291
- package/dist/config/schema/agent-overrides.d.ts +80 -16
- package/dist/config/schema/experimental.d.ts +0 -1
- package/dist/config/schema/hooks.d.ts +0 -1
- package/dist/config/schema/internal/permission.d.ts +5 -1
- package/dist/config/schema/oh-my-opencode-config.d.ts +75 -16
- package/dist/create-hooks.d.ts +0 -1
- package/dist/features/background-agent/index.d.ts +1 -1
- package/dist/features/background-agent/manager.d.ts +6 -0
- package/dist/features/background-agent/types.d.ts +2 -0
- package/dist/features/claude-code-plugin-loader/types.d.ts +3 -0
- package/dist/features/claude-code-session-state/state.d.ts +1 -0
- package/dist/features/skill-mcp-manager/manager.d.ts +11 -7
- package/dist/features/team-mode/team-mailbox/pending-delivery-recovery.d.ts +31 -0
- package/dist/features/team-mode/team-runtime/delete-team.d.ts +2 -1
- package/dist/features/team-mode/tools/lifecycle-inline-spec.d.ts +2 -2
- package/dist/features/tmux-subagent/stale-tmux-resource-sweeper.d.ts +12 -0
- package/dist/features/tool-metadata-store/store.d.ts +5 -0
- package/dist/hooks/anthropic-context-window-limit-recovery/storage/constants.d.ts +3 -0
- package/dist/hooks/{session-recovery → anthropic-context-window-limit-recovery}/storage/messages-reader.d.ts +1 -1
- package/dist/hooks/{session-recovery → anthropic-context-window-limit-recovery}/storage/part-content.d.ts +1 -1
- package/dist/hooks/{session-recovery → anthropic-context-window-limit-recovery}/storage/parts-reader.d.ts +1 -1
- package/dist/hooks/{session-recovery → anthropic-context-window-limit-recovery/storage}/types.d.ts +0 -13
- package/dist/hooks/auto-update-checker/checker/bundled-version.d.ts +1 -0
- package/dist/hooks/auto-update-checker/checker.d.ts +1 -0
- package/dist/hooks/auto-update-checker/constants.d.ts +3 -3
- package/dist/hooks/auto-update-checker/hook.d.ts +2 -1
- package/dist/hooks/claude-code-hooks/types.d.ts +4 -0
- package/dist/hooks/index.d.ts +0 -1
- package/dist/hooks/team-session-events/team-idle-wake-hint.d.ts +5 -0
- package/dist/index.js +2991 -2367
- package/dist/oh-my-opencode.schema.json +120 -18
- package/dist/plugin/build-team-idle-wake-hint-client.d.ts +2 -0
- package/dist/plugin/event-session-lifecycle.d.ts +0 -3
- package/dist/plugin/hooks/create-continuation-hooks.d.ts +0 -6
- package/dist/plugin/hooks/create-core-hooks.d.ts +0 -1
- package/dist/plugin/hooks/create-session-hooks.d.ts +1 -2
- package/dist/shared/command-executor/execute-hook-command.d.ts +7 -0
- package/dist/shared/plugin-identity.d.ts +2 -2
- package/dist/shared/tmux/tmux-utils/server-health.d.ts +2 -1
- package/dist/shared/tmux/tmux-utils/stale-attach-pane-sweep.d.ts +16 -0
- package/dist/shared/tmux/tmux-utils.d.ts +1 -0
- package/dist/tools/background-task/clients.d.ts +2 -0
- package/dist/tools/background-task/full-session-format.d.ts +1 -0
- package/dist/tools/background-task/types.d.ts +1 -0
- package/dist/tools/delegate-task/sync-prompt-sender.d.ts +1 -1
- package/dist/tools/delegate-task/sync-session-lifecycle.d.ts +2 -1
- package/dist/tools/look-at/look-at-input-preparer.d.ts +6 -2
- package/dist/tools/look-at/look-at-prompt.d.ts +2 -1
- package/dist/tools/look-at/look-at-session-runner.d.ts +3 -4
- package/dist/tools/look-at/types.d.ts +2 -0
- package/dist/tools/session-manager/types.d.ts +1 -0
- package/dist/tools/skill-mcp/types.d.ts +1 -0
- package/package.json +14 -13
- package/packages/ast-grep-mcp/dist/cli.js +50 -17
- package/packages/lsp-daemon/dist/cli.js +8 -5
- package/packages/lsp-daemon/dist/index.js +8 -5
- package/packages/lsp-tools-mcp/dist/lsp/connection.js +1 -1
- package/packages/lsp-tools-mcp/dist/lsp/server-definitions.js +2 -2
- package/packages/lsp-tools-mcp/dist/lsp/transport.d.ts +10 -1
- package/packages/lsp-tools-mcp/dist/lsp/transport.js +6 -3
- package/packages/omo-codex/lazycodex-repository/.github/workflows/pr-source-guidance.yml +11 -12
- package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/dist/cli.js +2583 -0
- package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +17 -0
- package/packages/omo-codex/plugin/components/bootstrap/manifests/ast-grep.json +22 -0
- package/packages/omo-codex/plugin/components/bootstrap/manifests/node.json +10 -0
- package/packages/omo-codex/plugin/components/bootstrap/package.json +20 -0
- package/packages/omo-codex/plugin/components/bootstrap/scripts/bootstrap.ps1 +310 -0
- package/packages/omo-codex/plugin/components/bootstrap/scripts/build.mjs +35 -0
- package/packages/omo-codex/plugin/components/bootstrap/scripts/generate-manifests.mjs +115 -0
- package/packages/omo-codex/plugin/components/bootstrap/src/cli.ts +153 -0
- package/packages/omo-codex/plugin/components/bootstrap/src/download.ts +212 -0
- package/packages/omo-codex/plugin/components/bootstrap/src/environment.ts +286 -0
- package/packages/omo-codex/plugin/components/bootstrap/src/hook.ts +108 -0
- package/packages/omo-codex/plugin/components/bootstrap/src/provision.ts +243 -0
- package/packages/omo-codex/plugin/components/bootstrap/src/setup.ts +294 -0
- package/packages/omo-codex/plugin/components/bootstrap/src/worker.ts +279 -0
- package/packages/omo-codex/plugin/components/bootstrap/test/download.test.ts +295 -0
- package/packages/omo-codex/plugin/components/bootstrap/test/environment.test.ts +375 -0
- package/packages/omo-codex/plugin/components/bootstrap/test/provision.test.ts +464 -0
- package/packages/omo-codex/plugin/components/bootstrap/tsconfig.json +25 -0
- package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/package.json +4 -4
- package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/git-bash/package.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/dist/codex-hook-cli.js +6 -10
- package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/package.json +4 -4
- package/packages/omo-codex/plugin/components/lsp/scripts/build-lsp-tools.test.mjs +8 -3
- package/packages/omo-codex/plugin/components/lsp/src/codex-hook-cli.ts +5 -8
- package/packages/omo-codex/plugin/components/lsp/test/codex-hook-cli.test.ts +24 -1
- package/packages/omo-codex/plugin/components/rules/bundled-rules/windows-git-bash.md +3 -1
- package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
- package/packages/omo-codex/plugin/components/rules/package.json +4 -4
- package/packages/omo-codex/plugin/components/rules/test/windows-git-bash-bundled-rule.test.ts +35 -1
- package/packages/omo-codex/plugin/components/start-work-continuation/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/start-work-continuation/package.json +4 -4
- package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/package.json +4 -4
- package/packages/omo-codex/plugin/components/ultrawork/biome.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/directive.md +155 -99
- package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/package.json +4 -4
- package/packages/omo-codex/plugin/components/ultrawork/skills/ulw-plan/SKILL.md +19 -51
- package/packages/omo-codex/plugin/components/ultrawork/skills/ulw-plan/references/full-workflow.md +46 -51
- package/packages/omo-codex/plugin/components/ultrawork/test/codex-hook.test.ts +19 -0
- package/packages/omo-codex/plugin/components/ultrawork/test/package-smoke.test.ts +0 -1
- package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-commands.js +9 -1
- package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-output.d.ts +1 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-output.js +18 -0
- package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-crud.js +1 -3
- package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/ulw-loop/package.json +4 -4
- package/packages/omo-codex/plugin/components/ulw-loop/src/cli-commands.ts +6 -2
- package/packages/omo-codex/plugin/components/ulw-loop/src/cli-output.ts +19 -0
- package/packages/omo-codex/plugin/components/ulw-loop/src/plan-crud.ts +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/test/cli-commands.test.ts +6 -0
- package/packages/omo-codex/plugin/components/ulw-loop/test/cli-complete-goals.test.ts +26 -1
- package/packages/omo-codex/plugin/components/ulw-loop/test/cli-json-errors.test.ts +89 -0
- package/packages/omo-codex/plugin/hooks/hooks.json +27 -16
- package/packages/omo-codex/plugin/package-lock.json +193 -193
- package/packages/omo-codex/plugin/package.json +1 -1
- package/packages/omo-codex/plugin/scripts/auto-update-state.d.mts +20 -0
- package/packages/omo-codex/plugin/scripts/auto-update.mjs +28 -8
- package/packages/omo-codex/plugin/scripts/build-components.mjs +36 -5
- package/packages/omo-codex/plugin/scripts/install-flow.mjs +43 -0
- package/packages/omo-codex/plugin/skills/lcx-contribute-bug-fix/SKILL.md +79 -28
- package/packages/omo-codex/plugin/skills/lcx-contribute-bug-fix/agents/openai.yaml +2 -2
- package/packages/omo-codex/plugin/skills/lcx-report-bug/SKILL.md +7 -6
- package/packages/omo-codex/plugin/skills/lcx-report-bug/agents/openai.yaml +1 -1
- package/packages/omo-codex/plugin/skills/ulw-plan/SKILL.md +19 -51
- package/packages/omo-codex/plugin/skills/ulw-plan/references/full-workflow.md +46 -51
- package/packages/omo-codex/plugin/test/aggregate-manifest.test.mjs +1 -0
- package/packages/omo-codex/plugin/test/auto-update.test.mjs +145 -0
- package/packages/omo-codex/plugin/test/bootstrap-binlinks.test.mjs +250 -0
- package/packages/omo-codex/plugin/test/bootstrap-hooks.test.mjs +166 -0
- package/packages/omo-codex/plugin/test/bootstrap-orchestration.test.mjs +371 -0
- package/packages/omo-codex/plugin/test/bootstrap-ps-guard.test.mjs +134 -0
- package/packages/omo-codex/plugin/test/bootstrap-setup.test.mjs +249 -0
- package/packages/omo-codex/plugin/test/lcx-bug-skills.test.mjs +10 -1
- package/packages/omo-codex/plugin/test/ulw-plan-skill.test.mjs +46 -0
- package/packages/omo-codex/scripts/atomic-write.test.mjs +82 -0
- package/packages/omo-codex/scripts/install/agents.d.mts +18 -0
- package/packages/omo-codex/scripts/install/agents.mjs +78 -5
- package/packages/omo-codex/scripts/install/atomic-write.mjs +59 -0
- package/packages/omo-codex/scripts/install/bin-dir.d.mts +7 -0
- package/packages/omo-codex/scripts/install/bin-links.d.mts +18 -0
- package/packages/omo-codex/scripts/install/config.d.mts +35 -0
- package/packages/omo-codex/scripts/install/config.mjs +13 -3
- package/packages/omo-codex/scripts/install/git-bash-mcp-env.d.mts +5 -0
- package/packages/omo-codex/scripts/install/git-bash.d.mts +23 -0
- package/packages/omo-codex/scripts/install/hook-trust.d.mts +10 -0
- package/packages/omo-codex/scripts/install-agent-links.test.mjs +41 -0
- package/packages/omo-codex/scripts/install-local.mjs +3 -2
- package/packages/shared-skills/skills/lcx-contribute-bug-fix/SKILL.md +79 -28
- package/packages/shared-skills/skills/lcx-contribute-bug-fix/agents/openai.yaml +2 -2
- package/packages/shared-skills/skills/lcx-report-bug/SKILL.md +7 -6
- package/packages/shared-skills/skills/lcx-report-bug/agents/openai.yaml +1 -1
- package/dist/hooks/session-recovery/constants.d.ts +0 -4
- package/dist/hooks/session-recovery/detect-error-type.d.ts +0 -4
- package/dist/hooks/session-recovery/error-recovery.d.ts +0 -4
- package/dist/hooks/session-recovery/hook-types.d.ts +0 -22
- package/dist/hooks/session-recovery/hook.d.ts +0 -4
- package/dist/hooks/session-recovery/index.d.ts +0 -5
- package/dist/hooks/session-recovery/interrupted-idle-message-fetch-timeout.d.ts +0 -7
- package/dist/hooks/session-recovery/interrupted-tool-results.d.ts +0 -3
- package/dist/hooks/session-recovery/message-state.d.ts +0 -4
- package/dist/hooks/session-recovery/recover-thinking-block-order.d.ts +0 -5
- package/dist/hooks/session-recovery/recover-thinking-disabled-violation.d.ts +0 -5
- package/dist/hooks/session-recovery/recover-tool-result-missing.d.ts +0 -10
- package/dist/hooks/session-recovery/recover-unavailable-tool.d.ts +0 -5
- package/dist/hooks/session-recovery/resume.d.ts +0 -7
- package/dist/hooks/session-recovery/storage/latest-assistant-message.d.ts +0 -5
- package/dist/hooks/session-recovery/storage/orphan-thinking-search.d.ts +0 -2
- package/dist/hooks/session-recovery/storage/thinking-block-search.d.ts +0 -2
- package/dist/hooks/session-recovery/storage/thinking-prepend.d.ts +0 -33
- package/dist/hooks/session-recovery/storage/thinking-strip.d.ts +0 -11
- package/dist/hooks/session-recovery/storage.d.ts +0 -20
- package/dist/plugin/event-session-recovery.d.ts +0 -9
- package/dist/plugin/user-abort-interrupted-recovery-guard.d.ts +0 -6
- /package/dist/hooks/{session-recovery → anthropic-context-window-limit-recovery}/storage/empty-messages.d.ts +0 -0
- /package/dist/hooks/{session-recovery → anthropic-context-window-limit-recovery}/storage/empty-text.d.ts +0 -0
- /package/dist/hooks/{session-recovery → anthropic-context-window-limit-recovery}/storage/message-dir.d.ts +0 -0
- /package/dist/hooks/{session-recovery → anthropic-context-window-limit-recovery}/storage/part-id.d.ts +0 -0
- /package/dist/hooks/{session-recovery → anthropic-context-window-limit-recovery}/storage/text-part-injector.d.ts +0 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
|
|
6
|
+
import { collectCommandHooks, readComponentHookManifests, readJson, root } from "./aggregate-plugin-fixture.mjs";
|
|
7
|
+
|
|
8
|
+
const PLUGIN_ROOT_TARGET_PATTERN = /\$\{PLUGIN_ROOT\}([^"']+)/g;
|
|
9
|
+
const SKIPPED_DIRECTORY_NAMES = new Set([".git", "node_modules"]);
|
|
10
|
+
const EXPECTED_BOOTSTRAP_COMMAND = 'node "${PLUGIN_ROOT}/components/bootstrap/dist/cli.js" hook session-start';
|
|
11
|
+
const EXPECTED_BOOTSTRAP_COMMAND_WINDOWS =
|
|
12
|
+
'powershell -NoProfile -ExecutionPolicy Bypass -File "${PLUGIN_ROOT}\\components\\bootstrap\\scripts\\bootstrap.ps1"';
|
|
13
|
+
|
|
14
|
+
async function pathExists(absolutePath) {
|
|
15
|
+
try {
|
|
16
|
+
await stat(absolutePath);
|
|
17
|
+
return true;
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function findHooksManifestPaths(directory, results = []) {
|
|
25
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
if (SKIPPED_DIRECTORY_NAMES.has(entry.name)) continue;
|
|
28
|
+
const path = join(directory, entry.name);
|
|
29
|
+
if (entry.isDirectory()) {
|
|
30
|
+
await findHooksManifestPaths(path, results);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (entry.name === "hooks.json") results.push(path);
|
|
34
|
+
}
|
|
35
|
+
return results;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function collectAsyncOptIns(value, manifestPath, offenders) {
|
|
39
|
+
if (Array.isArray(value)) {
|
|
40
|
+
for (const entry of value) collectAsyncOptIns(entry, manifestPath, offenders);
|
|
41
|
+
return offenders;
|
|
42
|
+
}
|
|
43
|
+
if (typeof value !== "object" || value === null) return offenders;
|
|
44
|
+
if (value.async === true) offenders.push(manifestPath);
|
|
45
|
+
for (const entry of Object.values(value)) collectAsyncOptIns(entry, manifestPath, offenders);
|
|
46
|
+
return offenders;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function collectPluginRootTargets(handler) {
|
|
50
|
+
const targets = [];
|
|
51
|
+
for (const commandText of [handler.command, handler.commandWindows]) {
|
|
52
|
+
if (typeof commandText !== "string") continue;
|
|
53
|
+
for (const match of commandText.matchAll(PLUGIN_ROOT_TARGET_PATTERN)) {
|
|
54
|
+
targets.push(match[1].split(/[\\/]/).filter((part) => part.length > 0));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return targets;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function readHookManifestsWithRoots() {
|
|
61
|
+
const aggregate = { source: "hooks/hooks.json", hooks: await readJson("hooks/hooks.json"), roots: [root] };
|
|
62
|
+
const components = (await readComponentHookManifests()).map(({ source, hooks }) => ({
|
|
63
|
+
source,
|
|
64
|
+
hooks,
|
|
65
|
+
roots: [dirname(dirname(join(root, source))), root],
|
|
66
|
+
}));
|
|
67
|
+
return [aggregate, ...components];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function findBootstrapSessionStartHandlers(hooks) {
|
|
71
|
+
const groups = Array.isArray(hooks.hooks?.SessionStart) ? hooks.hooks.SessionStart : [];
|
|
72
|
+
const located = [];
|
|
73
|
+
for (const group of groups) {
|
|
74
|
+
for (const handler of group.hooks ?? []) {
|
|
75
|
+
if (typeof handler.command !== "string") continue;
|
|
76
|
+
if (!handler.command.includes("components/bootstrap/dist/cli.js")) continue;
|
|
77
|
+
located.push({ group, handler });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return located;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
test("#given every hooks.json under the plugin #when handlers are inspected #then no entry opts into unsupported async execution", async () => {
|
|
84
|
+
// given
|
|
85
|
+
const manifestPaths = await findHooksManifestPaths(root);
|
|
86
|
+
|
|
87
|
+
// when
|
|
88
|
+
const offenders = [];
|
|
89
|
+
for (const manifestPath of manifestPaths) {
|
|
90
|
+
collectAsyncOptIns(JSON.parse(await readFile(manifestPath, "utf8")), manifestPath, offenders);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// then
|
|
94
|
+
assert(manifestPaths.length >= 2, "expected the aggregate and component hooks manifests to be discovered");
|
|
95
|
+
assert.deepEqual(offenders, [], `Codex skips async hooks silently; remove "async": true from: ${offenders.join(", ")}`);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("#given aggregate and component hook manifests #when command targets are resolved #then every command and commandWindows target exists", async () => {
|
|
99
|
+
// given
|
|
100
|
+
const manifests = await readHookManifestsWithRoots();
|
|
101
|
+
|
|
102
|
+
// when
|
|
103
|
+
const missing = [];
|
|
104
|
+
for (const manifest of manifests) {
|
|
105
|
+
for (const { handler } of collectCommandHooks(manifest.hooks, manifest.source)) {
|
|
106
|
+
for (const targetParts of collectPluginRootTargets(handler)) {
|
|
107
|
+
const candidates = manifest.roots.map((rootPath) => join(rootPath, ...targetParts));
|
|
108
|
+
let found = false;
|
|
109
|
+
for (const candidate of candidates) {
|
|
110
|
+
if (await pathExists(candidate)) {
|
|
111
|
+
found = true;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!found) missing.push(`${manifest.source}: ${targetParts.join("/")}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// then
|
|
121
|
+
assert.deepEqual(missing, []);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("#given the bootstrap component #when its SessionStart registration is inspected #then aggregate and component entries declare both platform commands", async () => {
|
|
125
|
+
// given
|
|
126
|
+
const aggregateHooks = await readJson("hooks/hooks.json");
|
|
127
|
+
const componentHooks = await readJson("components/bootstrap/hooks/hooks.json");
|
|
128
|
+
|
|
129
|
+
// when
|
|
130
|
+
const aggregateEntries = findBootstrapSessionStartHandlers(aggregateHooks);
|
|
131
|
+
const componentEntries = findBootstrapSessionStartHandlers(componentHooks);
|
|
132
|
+
|
|
133
|
+
// then
|
|
134
|
+
for (const [label, entries] of [
|
|
135
|
+
["aggregate", aggregateEntries],
|
|
136
|
+
["component", componentEntries],
|
|
137
|
+
]) {
|
|
138
|
+
assert.equal(entries.length, 1, `${label} hooks.json must register exactly one bootstrap SessionStart handler`);
|
|
139
|
+
const { group, handler } = entries[0];
|
|
140
|
+
assert.equal(group.matcher, undefined, `${label} bootstrap SessionStart entry must be matcher-less`);
|
|
141
|
+
assert.equal(handler.type, "command");
|
|
142
|
+
assert.equal(handler.command, EXPECTED_BOOTSTRAP_COMMAND);
|
|
143
|
+
assert.equal(handler.commandWindows, EXPECTED_BOOTSTRAP_COMMAND_WINDOWS);
|
|
144
|
+
assert.equal(typeof handler.timeout, "number");
|
|
145
|
+
assert(handler.timeout <= 60, `${label} bootstrap timeout must stay <= 60 seconds`);
|
|
146
|
+
assert.equal(typeof handler.statusMessage, "string");
|
|
147
|
+
assert.match(handler.statusMessage, /^LazyCodex\([^)]+\): .+$/);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("#given the built bootstrap bundle #when its module references are inspected #then it depends on Node built-ins only", async () => {
|
|
152
|
+
// given
|
|
153
|
+
const bundlePath = join(root, "components", "bootstrap", "dist", "cli.js");
|
|
154
|
+
assert.equal(await pathExists(bundlePath), true, "components/bootstrap/dist/cli.js must exist after the plugin build");
|
|
155
|
+
const bundle = await readFile(bundlePath, "utf8");
|
|
156
|
+
|
|
157
|
+
// when
|
|
158
|
+
const externalSpecifiers = [
|
|
159
|
+
...[...bundle.matchAll(/\brequire\(["']([^"']+)["']\)/g)].map((match) => match[1]),
|
|
160
|
+
...[...bundle.matchAll(/\bfrom\s*["']([^"']+)["']/g)].map((match) => match[1]),
|
|
161
|
+
].filter((specifier) => !specifier.startsWith("node:"));
|
|
162
|
+
|
|
163
|
+
// then
|
|
164
|
+
assert(bundle.length > 0, "bootstrap bundle must not be empty");
|
|
165
|
+
assert.deepEqual(externalSpecifiers, [], "bootstrap dist must bundle everything except node: built-ins");
|
|
166
|
+
});
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const CLI_URL = new URL("../components/bootstrap/dist/cli.js", import.meta.url);
|
|
9
|
+
const CLI_PATH = fileURLToPath(CLI_URL);
|
|
10
|
+
const {
|
|
11
|
+
BOOTSTRAP_RESTART_NOTICE,
|
|
12
|
+
bootstrapLocks,
|
|
13
|
+
executeSessionStartHook,
|
|
14
|
+
parseWorkerFlags,
|
|
15
|
+
runBootstrapWorker,
|
|
16
|
+
runSessionStartHook,
|
|
17
|
+
} = await import(CLI_URL.href);
|
|
18
|
+
|
|
19
|
+
const PLUGIN_VERSION = "9.9.9";
|
|
20
|
+
|
|
21
|
+
async function withFixture(run, options = {}) {
|
|
22
|
+
const root = await mkdtemp(join(tmpdir(), "omo-bootstrap-orch-"));
|
|
23
|
+
try {
|
|
24
|
+
const pluginRoot = join(root, "plugin");
|
|
25
|
+
const pluginData = join(root, "plugin-data");
|
|
26
|
+
await mkdir(join(pluginRoot, ".codex-plugin"), { recursive: true });
|
|
27
|
+
await mkdir(pluginData, { recursive: true });
|
|
28
|
+
await writeFile(
|
|
29
|
+
join(pluginRoot, ".codex-plugin", "plugin.json"),
|
|
30
|
+
`${JSON.stringify({ name: "omo", version: options.version ?? PLUGIN_VERSION })}\n`,
|
|
31
|
+
);
|
|
32
|
+
await run({
|
|
33
|
+
env: { PLUGIN_DATA: pluginData, PLUGIN_ROOT: pluginRoot },
|
|
34
|
+
pluginData,
|
|
35
|
+
pluginRoot,
|
|
36
|
+
root,
|
|
37
|
+
});
|
|
38
|
+
} finally {
|
|
39
|
+
await rm(root, { force: true, recursive: true });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function hookRecorder() {
|
|
44
|
+
const notices = [];
|
|
45
|
+
const spawned = [];
|
|
46
|
+
return {
|
|
47
|
+
notices,
|
|
48
|
+
spawned,
|
|
49
|
+
spawnWorker: (invocation) => {
|
|
50
|
+
spawned.push(invocation);
|
|
51
|
+
},
|
|
52
|
+
writeNotice: (line) => {
|
|
53
|
+
notices.push(line);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function readStateFile(pluginData) {
|
|
59
|
+
return JSON.parse(await readFile(join(pluginData, "bootstrap", "state.json"), "utf8"));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function writeStateFile(pluginData, state) {
|
|
63
|
+
await mkdir(join(pluginData, "bootstrap"), { recursive: true });
|
|
64
|
+
await writeFile(join(pluginData, "bootstrap", "state.json"), `${JSON.stringify(state)}\n`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
test("#given a fresh PLUGIN_DATA #when the session-start hook runs #then it spawns the detached worker and emits the restart notice", async () => {
|
|
68
|
+
await withFixture(async (fixture) => {
|
|
69
|
+
const recorder = hookRecorder();
|
|
70
|
+
|
|
71
|
+
const result = await executeSessionStartHook({ env: fixture.env, ...recorder });
|
|
72
|
+
|
|
73
|
+
assert.equal(result.exitCode, 0);
|
|
74
|
+
assert.equal(result.action, "spawned");
|
|
75
|
+
assert.equal(recorder.spawned.length, 1);
|
|
76
|
+
assert.equal(recorder.spawned[0].command, process.execPath);
|
|
77
|
+
assert.deepEqual(recorder.spawned[0].args, [CLI_PATH, "worker"]);
|
|
78
|
+
assert.equal(recorder.spawned[0].env.PLUGIN_DATA, fixture.pluginData);
|
|
79
|
+
assert.equal(recorder.notices.length, 1);
|
|
80
|
+
assert.deepEqual(JSON.parse(recorder.notices[0]), {
|
|
81
|
+
hookSpecificOutput: {
|
|
82
|
+
hookEventName: "SessionStart",
|
|
83
|
+
additionalContext: BOOTSTRAP_RESTART_NOTICE,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("#given a completed marker for the current version #when the hook runs #then it exits 0 silently without spawning", async () => {
|
|
90
|
+
await withFixture(async (fixture) => {
|
|
91
|
+
await writeStateFile(fixture.pluginData, { completedForVersion: PLUGIN_VERSION, lastAttemptAt: 1, lastStatus: "success" });
|
|
92
|
+
const recorder = hookRecorder();
|
|
93
|
+
|
|
94
|
+
const result = await executeSessionStartHook({ env: fixture.env, ...recorder });
|
|
95
|
+
|
|
96
|
+
assert.equal(result.exitCode, 0);
|
|
97
|
+
assert.equal(result.action, "skip-completed");
|
|
98
|
+
assert.deepEqual(recorder.spawned, []);
|
|
99
|
+
assert.deepEqual(recorder.notices, []);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("#given a marker stamped for an older plugin version #when the hook runs #then the version bump re-spawns the worker", async () => {
|
|
104
|
+
await withFixture(async (fixture) => {
|
|
105
|
+
await writeStateFile(fixture.pluginData, { completedForVersion: "0.0.1", lastAttemptAt: 1, lastStatus: "success" });
|
|
106
|
+
const recorder = hookRecorder();
|
|
107
|
+
|
|
108
|
+
const result = await executeSessionStartHook({ env: fixture.env, ...recorder });
|
|
109
|
+
|
|
110
|
+
assert.equal(result.action, "spawned");
|
|
111
|
+
assert.equal(recorder.spawned.length, 1);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("#given a fresh bootstrap lock held by another process #when the hook runs #then it exits 0 without spawning", async () => {
|
|
116
|
+
await withFixture(async (fixture) => {
|
|
117
|
+
await mkdir(join(fixture.pluginData, "bootstrap"), { recursive: true });
|
|
118
|
+
await writeFile(join(fixture.pluginData, "bootstrap", "state.json.lock"), `${Date.now()}\n`);
|
|
119
|
+
const recorder = hookRecorder();
|
|
120
|
+
|
|
121
|
+
const result = await executeSessionStartHook({ env: fixture.env, ...recorder });
|
|
122
|
+
|
|
123
|
+
assert.equal(result.exitCode, 0);
|
|
124
|
+
assert.equal(result.action, "skip-locked");
|
|
125
|
+
assert.deepEqual(recorder.spawned, []);
|
|
126
|
+
assert.deepEqual(recorder.notices, []);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("#given a stale bootstrap lock #when the hook runs #then it spawns anyway", async () => {
|
|
131
|
+
await withFixture(async (fixture) => {
|
|
132
|
+
await mkdir(join(fixture.pluginData, "bootstrap"), { recursive: true });
|
|
133
|
+
await writeFile(join(fixture.pluginData, "bootstrap", "state.json.lock"), "1\n");
|
|
134
|
+
const staleNow = Date.now() + 11 * 60 * 1_000;
|
|
135
|
+
const recorder = hookRecorder();
|
|
136
|
+
|
|
137
|
+
const result = await executeSessionStartHook({ env: fixture.env, now: staleNow, ...recorder });
|
|
138
|
+
|
|
139
|
+
assert.equal(result.action, "spawned");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("#given PLUGIN_DATA or PLUGIN_ROOT missing #when the hook runs #then it exits 0 silently", async () => {
|
|
144
|
+
await withFixture(async (fixture) => {
|
|
145
|
+
const recorder = hookRecorder();
|
|
146
|
+
|
|
147
|
+
const noData = await executeSessionStartHook({ env: { PLUGIN_ROOT: fixture.pluginRoot }, ...recorder });
|
|
148
|
+
const noRoot = await executeSessionStartHook({ env: { PLUGIN_DATA: fixture.pluginData }, ...recorder });
|
|
149
|
+
|
|
150
|
+
assert.equal(noData.exitCode, 0);
|
|
151
|
+
assert.equal(noData.action, "skip-missing-env");
|
|
152
|
+
assert.equal(noRoot.action, "skip-missing-env");
|
|
153
|
+
assert.deepEqual(recorder.spawned, []);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("#given an unreadable plugin version manifest #when the hook runs #then it exits 0 without spawning", async () => {
|
|
158
|
+
await withFixture(async (fixture) => {
|
|
159
|
+
await rm(join(fixture.pluginRoot, ".codex-plugin", "plugin.json"));
|
|
160
|
+
const recorder = hookRecorder();
|
|
161
|
+
|
|
162
|
+
const result = await executeSessionStartHook({ env: fixture.env, ...recorder });
|
|
163
|
+
|
|
164
|
+
assert.equal(result.exitCode, 0);
|
|
165
|
+
assert.equal(result.action, "skip-version-unresolved");
|
|
166
|
+
assert.deepEqual(recorder.spawned, []);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("#given the public hook runner #when every branch resolves #then it always returns exit code 0", async () => {
|
|
171
|
+
await withFixture(async (fixture) => {
|
|
172
|
+
const recorder = hookRecorder();
|
|
173
|
+
|
|
174
|
+
assert.equal(await runSessionStartHook({ env: fixture.env, ...recorder }), 0);
|
|
175
|
+
assert.equal(await runSessionStartHook({ env: {}, ...recorder }), 0);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("#given a fresh state #when the worker runs #then steps execute and the versioned success marker is persisted", async () => {
|
|
180
|
+
await withFixture(async (fixture) => {
|
|
181
|
+
const contexts = [];
|
|
182
|
+
const now = 42_000;
|
|
183
|
+
|
|
184
|
+
const result = await runBootstrapWorker({
|
|
185
|
+
argv: ["--codex-home", join(fixture.root, "codex-home")],
|
|
186
|
+
env: fixture.env,
|
|
187
|
+
now,
|
|
188
|
+
steps: [
|
|
189
|
+
{
|
|
190
|
+
name: "setup",
|
|
191
|
+
run: async (context) => {
|
|
192
|
+
contexts.push(context);
|
|
193
|
+
return { degraded: [] };
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
assert.equal(result.ran, true);
|
|
200
|
+
assert.equal(result.status, "success");
|
|
201
|
+
assert.equal(contexts.length, 1);
|
|
202
|
+
assert.equal(contexts[0].codexHome, join(fixture.root, "codex-home"));
|
|
203
|
+
assert.equal(contexts[0].pluginRoot, fixture.pluginRoot);
|
|
204
|
+
assert.equal(contexts[0].pluginData, fixture.pluginData);
|
|
205
|
+
assert.equal(contexts[0].pluginVersion, PLUGIN_VERSION);
|
|
206
|
+
assert.deepEqual(await readStateFile(fixture.pluginData), {
|
|
207
|
+
completedForVersion: PLUGIN_VERSION,
|
|
208
|
+
degraded: [],
|
|
209
|
+
lastAttemptAt: now,
|
|
210
|
+
lastStatus: "success",
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("#given a marker written between hook and worker start #when the worker re-checks under lock #then it honors the marker (TOCTOU)", async () => {
|
|
216
|
+
await withFixture(async (fixture) => {
|
|
217
|
+
await writeStateFile(fixture.pluginData, { completedForVersion: PLUGIN_VERSION, lastAttemptAt: 7, lastStatus: "success" });
|
|
218
|
+
let stepRuns = 0;
|
|
219
|
+
|
|
220
|
+
const result = await runBootstrapWorker({
|
|
221
|
+
env: fixture.env,
|
|
222
|
+
steps: [
|
|
223
|
+
{
|
|
224
|
+
name: "setup",
|
|
225
|
+
run: async () => {
|
|
226
|
+
stepRuns += 1;
|
|
227
|
+
return { degraded: [] };
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
assert.deepEqual(result, { ran: false, reason: "already-completed" });
|
|
234
|
+
assert.equal(stepRuns, 0);
|
|
235
|
+
assert.deepEqual(await readStateFile(fixture.pluginData), {
|
|
236
|
+
completedForVersion: PLUGIN_VERSION,
|
|
237
|
+
lastAttemptAt: 7,
|
|
238
|
+
lastStatus: "success",
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("#given both locks already held #when the worker starts #then it is refused without writing state", async () => {
|
|
244
|
+
await withFixture(async (fixture) => {
|
|
245
|
+
const locks = await bootstrapLocks({ env: fixture.env, pluginData: fixture.pluginData });
|
|
246
|
+
assert.notEqual(locks, null);
|
|
247
|
+
|
|
248
|
+
const result = await runBootstrapWorker({ env: fixture.env, steps: [] });
|
|
249
|
+
|
|
250
|
+
assert.deepEqual(result, { ran: false, reason: "locked" });
|
|
251
|
+
await assert.rejects(() => stat(join(fixture.pluginData, "bootstrap", "state.json")));
|
|
252
|
+
await locks.release();
|
|
253
|
+
await assert.rejects(() => stat(locks.bootstrapLockPath));
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("#given throwing and degraded steps #when the worker runs #then degraded reasons are persisted and the worker still exits cleanly", async () => {
|
|
258
|
+
await withFixture(async (fixture) => {
|
|
259
|
+
const now = 84_000;
|
|
260
|
+
|
|
261
|
+
const result = await runBootstrapWorker({
|
|
262
|
+
env: fixture.env,
|
|
263
|
+
now,
|
|
264
|
+
steps: [
|
|
265
|
+
{
|
|
266
|
+
name: "setup",
|
|
267
|
+
run: async () => {
|
|
268
|
+
throw new Error("config.toml unwritable");
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: "sg",
|
|
273
|
+
run: async () => ({
|
|
274
|
+
degraded: [{ component: "ast_grep", hint: "npx lazycodex-ai doctor", reason: "checksum mismatch for darwin-arm64" }],
|
|
275
|
+
}),
|
|
276
|
+
},
|
|
277
|
+
],
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
assert.equal(result.ran, true);
|
|
281
|
+
assert.equal(result.status, "degraded");
|
|
282
|
+
const state = await readStateFile(fixture.pluginData);
|
|
283
|
+
assert.equal(state.completedForVersion, PLUGIN_VERSION);
|
|
284
|
+
assert.equal(state.lastAttemptAt, now);
|
|
285
|
+
assert.equal(state.lastStatus, "degraded");
|
|
286
|
+
assert.equal(state.degraded.length, 2);
|
|
287
|
+
assert.deepEqual(state.degraded[0], {
|
|
288
|
+
component: "setup",
|
|
289
|
+
hint: "npx lazycodex-ai doctor",
|
|
290
|
+
reason: "config.toml unwritable",
|
|
291
|
+
});
|
|
292
|
+
assert.deepEqual(state.degraded[1], {
|
|
293
|
+
component: "ast_grep",
|
|
294
|
+
hint: "npx lazycodex-ai doctor",
|
|
295
|
+
reason: "checksum mismatch for darwin-arm64",
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("#given a completed marker #when the worker runs with --once #then the marker is bypassed and steps run again", async () => {
|
|
301
|
+
await withFixture(async (fixture) => {
|
|
302
|
+
await writeStateFile(fixture.pluginData, { completedForVersion: PLUGIN_VERSION, lastAttemptAt: 7, lastStatus: "success" });
|
|
303
|
+
let stepRuns = 0;
|
|
304
|
+
|
|
305
|
+
const result = await runBootstrapWorker({
|
|
306
|
+
argv: ["--once"],
|
|
307
|
+
env: fixture.env,
|
|
308
|
+
steps: [
|
|
309
|
+
{
|
|
310
|
+
name: "setup",
|
|
311
|
+
run: async () => {
|
|
312
|
+
stepRuns += 1;
|
|
313
|
+
return { degraded: [] };
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
],
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
assert.equal(result.ran, true);
|
|
320
|
+
assert.equal(stepRuns, 1);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("#given --only sg #when the worker runs #then only the matching step executes", async () => {
|
|
325
|
+
await withFixture(async (fixture) => {
|
|
326
|
+
const ran = [];
|
|
327
|
+
const step = (name) => ({
|
|
328
|
+
name,
|
|
329
|
+
run: async () => {
|
|
330
|
+
ran.push(name);
|
|
331
|
+
return { degraded: [] };
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const result = await runBootstrapWorker({
|
|
336
|
+
argv: ["--only", "sg"],
|
|
337
|
+
env: fixture.env,
|
|
338
|
+
steps: [step("setup"), step("sg")],
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
assert.equal(result.ran, true);
|
|
342
|
+
assert.deepEqual(ran, ["sg"]);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("#given worker CLI flags #when parsed #then --codex-home/--once/--only/--manifest-dir are recognized and unknown flags throw", () => {
|
|
347
|
+
assert.deepEqual(parseWorkerFlags(["--codex-home", "/x", "--once", "--only", "sg", "--manifest-dir", "/m"]), {
|
|
348
|
+
codexHome: "/x",
|
|
349
|
+
manifestDir: "/m",
|
|
350
|
+
once: true,
|
|
351
|
+
only: "sg",
|
|
352
|
+
});
|
|
353
|
+
assert.deepEqual(parseWorkerFlags([]), { once: false });
|
|
354
|
+
assert.throws(() => parseWorkerFlags(["--bogus"]), /--bogus/);
|
|
355
|
+
assert.throws(() => parseWorkerFlags(["--codex-home"]), /--codex-home/);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("#given a missing plugin version in the worker #when it runs #then it records a degraded state instead of crashing", async () => {
|
|
359
|
+
await withFixture(async (fixture) => {
|
|
360
|
+
await rm(join(fixture.pluginRoot, ".codex-plugin", "plugin.json"));
|
|
361
|
+
|
|
362
|
+
const result = await runBootstrapWorker({ env: fixture.env, now: 5_000, steps: [] });
|
|
363
|
+
|
|
364
|
+
assert.equal(result.ran, true);
|
|
365
|
+
assert.equal(result.status, "degraded");
|
|
366
|
+
const state = await readStateFile(fixture.pluginData);
|
|
367
|
+
assert.equal(state.completedForVersion, undefined);
|
|
368
|
+
assert.equal(state.lastStatus, "degraded");
|
|
369
|
+
assert.match(state.degraded[0].reason, /plugin version/i);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
|
|
6
|
+
import { collectCommandHooks, readJson, root } from "./aggregate-plugin-fixture.mjs";
|
|
7
|
+
|
|
8
|
+
const BOOTSTRAP_SCRIPT_RELATIVE_PATH = join("components", "bootstrap", "scripts", "bootstrap.ps1");
|
|
9
|
+
const HOOK_MANIFEST_SOURCES = ["hooks/hooks.json", "components/bootstrap/hooks/hooks.json"];
|
|
10
|
+
const BOOTSTRAP_PS1_COMMAND_WINDOWS_TARGET = "\\components\\bootstrap\\scripts\\bootstrap.ps1";
|
|
11
|
+
const TLS12_LINE_PATTERN =
|
|
12
|
+
/\[Net\.ServicePointManager\]::SecurityProtocol\s*=\s*\[Net\.ServicePointManager\]::SecurityProtocol\s+-bor\s+\[Net\.SecurityProtocolType\]::Tls12/;
|
|
13
|
+
const PWSH7_ONLY_TOKENS = [
|
|
14
|
+
{ token: "??", pattern: /\?\?/, reason: "null-coalescing operator requires pwsh 7" },
|
|
15
|
+
{ token: "?.", pattern: /\?\./, reason: "null-conditional member access requires pwsh 7" },
|
|
16
|
+
{ token: "-Parallel", pattern: /-Parallel\b/i, reason: "ForEach-Object -Parallel requires pwsh 7" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
async function readBootstrapScript() {
|
|
20
|
+
return readFile(join(root, BOOTSTRAP_SCRIPT_RELATIVE_PATH), "utf8");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function numberedLines(content) {
|
|
24
|
+
return content.split(/\r?\n/).map((text, index) => ({ line: index + 1, text }));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("#given bootstrap.ps1 #when the download preamble is inspected #then it forces TLS 1.2 on ServicePointManager", async () => {
|
|
28
|
+
// given
|
|
29
|
+
const content = await readBootstrapScript();
|
|
30
|
+
|
|
31
|
+
// then
|
|
32
|
+
assert.match(
|
|
33
|
+
content,
|
|
34
|
+
TLS12_LINE_PATTERN,
|
|
35
|
+
"bootstrap.ps1 must OR Tls12 into [Net.ServicePointManager]::SecurityProtocol before any Invoke-WebRequest",
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("#given bootstrap.ps1 #when scanned for pwsh-7-only syntax #then Windows PowerShell 5.1 can parse every line", async () => {
|
|
40
|
+
// given
|
|
41
|
+
const content = await readBootstrapScript();
|
|
42
|
+
|
|
43
|
+
// when
|
|
44
|
+
const offenders = [];
|
|
45
|
+
for (const { token, pattern, reason } of PWSH7_ONLY_TOKENS) {
|
|
46
|
+
for (const { line, text } of numberedLines(content)) {
|
|
47
|
+
if (pattern.test(text)) {
|
|
48
|
+
offenders.push(`pwsh-7-only token \`${token}\` (${reason}) at bootstrap.ps1:${line}: ${text.trim()}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// then
|
|
54
|
+
assert.deepEqual(offenders, [], "bootstrap.ps1 runs under Windows PowerShell 5.1 (System32); pwsh-7-only syntax breaks it");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("#given bootstrap.ps1 #when environment writes are scanned #then no Machine-scope target appears", async () => {
|
|
58
|
+
// given
|
|
59
|
+
const content = await readBootstrapScript();
|
|
60
|
+
|
|
61
|
+
// when
|
|
62
|
+
const offenders = numberedLines(content)
|
|
63
|
+
.filter(({ text }) => /\bmachine\b/i.test(text))
|
|
64
|
+
.map(({ line, text }) => `Machine-scope token at bootstrap.ps1:${line}: ${text.trim()}`);
|
|
65
|
+
|
|
66
|
+
// then
|
|
67
|
+
assert.deepEqual(offenders, [], "bootstrap.ps1 must only touch User-scope environment values (Machine scope needs elevation)");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("#given bootstrap.ps1 #when execution policy mutations are scanned #then Set-ExecutionPolicy is never invoked", async () => {
|
|
71
|
+
// given
|
|
72
|
+
const content = await readBootstrapScript();
|
|
73
|
+
|
|
74
|
+
// when
|
|
75
|
+
const offenders = numberedLines(content)
|
|
76
|
+
.filter(({ text }) => /set-executionpolicy/i.test(text))
|
|
77
|
+
.map(({ line, text }) => `Set-ExecutionPolicy at bootstrap.ps1:${line}: ${text.trim()}`);
|
|
78
|
+
|
|
79
|
+
// then
|
|
80
|
+
assert.deepEqual(offenders, [], "the hook line already passes -ExecutionPolicy Bypass; the script must not change policy");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("#given bootstrap.ps1 #when the final executable statement is located #then it is `exit 0` so sessions are never blocked", async () => {
|
|
84
|
+
// given
|
|
85
|
+
const content = await readBootstrapScript();
|
|
86
|
+
|
|
87
|
+
// when
|
|
88
|
+
const statements = content
|
|
89
|
+
.split(/\r?\n/)
|
|
90
|
+
.map((line) => line.trim())
|
|
91
|
+
.filter((line) => line.length > 0 && !line.startsWith("#"));
|
|
92
|
+
|
|
93
|
+
// then
|
|
94
|
+
assert.equal(statements.at(-1), "exit 0", "the terminal statement of bootstrap.ps1 must be `exit 0`");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("#given bootstrap.ps1 #when every character is inspected #then the script is ASCII-only for cp949-safe consoles", async () => {
|
|
98
|
+
// given
|
|
99
|
+
const content = await readBootstrapScript();
|
|
100
|
+
|
|
101
|
+
// when
|
|
102
|
+
const offenders = [];
|
|
103
|
+
for (const { line, text } of numberedLines(content)) {
|
|
104
|
+
for (const character of text) {
|
|
105
|
+
const codePoint = character.codePointAt(0);
|
|
106
|
+
if (codePoint > 0x7f) {
|
|
107
|
+
offenders.push(`non-ASCII U+${codePoint.toString(16).toUpperCase().padStart(4, "0")} at bootstrap.ps1:${line}: ${text.trim()}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// then
|
|
113
|
+
assert.deepEqual(offenders, [], "bootstrap.ps1 output and source must be ASCII-only (cp949 consoles mangle anything else)");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("#given the aggregate and bootstrap component hook manifests #when commandWindows entries are read #then the bootstrap SessionStart hook launches bootstrap.ps1", async () => {
|
|
117
|
+
for (const source of HOOK_MANIFEST_SOURCES) {
|
|
118
|
+
// given
|
|
119
|
+
const hooks = await readJson(source);
|
|
120
|
+
|
|
121
|
+
// when
|
|
122
|
+
const launchers = collectCommandHooks(hooks, source)
|
|
123
|
+
.map(({ handler }) => handler.commandWindows)
|
|
124
|
+
.filter((command) => typeof command === "string" && command.includes(BOOTSTRAP_PS1_COMMAND_WINDOWS_TARGET));
|
|
125
|
+
|
|
126
|
+
// then
|
|
127
|
+
assert.equal(launchers.length, 1, `${source} must register exactly one commandWindows pointing at bootstrap.ps1`);
|
|
128
|
+
assert.match(
|
|
129
|
+
launchers[0],
|
|
130
|
+
/^powershell -NoProfile -ExecutionPolicy Bypass -File /,
|
|
131
|
+
`${source} must launch bootstrap.ps1 with -NoProfile and -File`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
});
|