diff --git a/.changeset/cross-platform-final-build.md b/.changeset/cross-platform-final-build.md new file mode 100644 index 00000000..260c76bb --- /dev/null +++ b/.changeset/cross-platform-final-build.md @@ -0,0 +1,6 @@ +--- +"@prover-coder-ai/docker-git": patch +"@prover-coder-ai/docker-git-session-sync": patch +--- + +Add portable launch/build scripts and CI final-build verification across Linux, macOS, and Windows. diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index cb33036e..0b19744d 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -22,6 +22,7 @@ runs: with: node-version: ${{ inputs.node-version }} - name: Install OpenSSH client + if: runner.os == 'Linux' shell: bash run: | if command -v ssh >/dev/null 2>&1 && command -v ssh-keygen >/dev/null 2>&1; then diff --git a/.github/workflows/final-build.yml b/.github/workflows/final-build.yml new file mode 100644 index 00000000..ce24f93b --- /dev/null +++ b/.github/workflows/final-build.yml @@ -0,0 +1,51 @@ +name: Final Build + +on: + workflow_dispatch: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + final-build: + name: Final build (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v6 + - name: Install dependencies + uses: ./.github/actions/setup + with: + bun-version: 1.3.11 + node-version: 24.14.0 + - name: Build final workspace packages + run: bun run build + - name: Verify docker-git CLI starts + run: bun ./packages/app/dist/src/docker-git/main.js --help + - name: Verify session sync CLI starts + run: bun ./packages/docker-git-session-sync/dist/docker-git-session-sync.js --help + - name: Verify browser UI and menu clone smoke checks + run: | + bun run --cwd packages/app build:web + bun scripts/final-build/browser-web-smoke.mjs + bun run --cwd packages/app vitest run tests/docker-git/browser-frontend.test.ts tests/docker-git/app-ready-create.test.ts tests/docker-git/actions-project-create.test.ts + - name: Prepare package artifacts directory + run: | + node -e "require('node:fs').mkdirSync('artifacts', { recursive: true })" + - name: Pack docker-git package + working-directory: packages/app + run: bun pm pack --quiet --ignore-scripts --destination ../../artifacts + - name: Pack session sync package + working-directory: packages/docker-git-session-sync + run: bun pm pack --quiet --ignore-scripts --destination ../../artifacts + - name: Upload final build artifacts + uses: actions/upload-artifact@v7 + with: + name: final-build-${{ matrix.os }} + path: artifacts/*.tgz diff --git a/bun.lock b/bun.lock index e0e2c218..d11dcc11 100644 --- a/bun.lock +++ b/bun.lock @@ -37,7 +37,7 @@ }, "packages/app": { "name": "@prover-coder-ai/docker-git", - "version": "1.1.1", + "version": "1.1.5", "bin": { "docker-git": "dist/src/docker-git/main.js", }, @@ -95,6 +95,7 @@ "eslint-plugin-sonarjs": "^4.0.3", "eslint-plugin-sort-destructure-keys": "^3.0.0", "eslint-plugin-unicorn": "^64.0.0", + "fast-check": "3.23.2", "globals": "^17.6.0", "jscpd": "^4.1.1", "typescript": "^6.0.3", @@ -106,7 +107,7 @@ }, "packages/docker-git-session-sync": { "name": "@prover-coder-ai/docker-git-session-sync", - "version": "1.0.5", + "version": "1.0.8", "bin": { "docker-git-session-sync": "dist/docker-git-session-sync.js", }, diff --git a/package.json b/package.json index 317073c8..f2993609 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,9 @@ "changeset": "changeset", "changeset-publish": "bun -e \"if (!process.env.NPM_TOKEN) { console.log('Skipping publish: NPM_TOKEN is not set'); process.exit(0); }\" && changeset publish", "changeset-version": "changeset version", - "clone": "bash -lc 'bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js clone \"$@\"' --", - "open": "bash -lc 'bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js open \"$@\"' --", - "docker-git": "bash -lc 'bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js \"$@\"' --", + "clone": "bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js clone", + "open": "bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js open", + "docker-git": "bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js", "skiller:init": "git submodule update --init --checkout third_party/skiller-desktop-skills-manager && bun scripts/skiller-apply-docker-git-patches.mjs", "skiller:install": "bun install --cwd third_party/skiller-desktop-skills-manager --frozen-lockfile", "skiller:dev": "bun run --cwd third_party/skiller-desktop-skills-manager dev", @@ -36,7 +36,7 @@ "e2e:login-context": "bash scripts/e2e/login-context.sh", "e2e:runtime-volumes-ssh": "bash scripts/e2e/runtime-volumes-ssh.sh", "e2e:opencode-autoconnect": "bash scripts/e2e/opencode-autoconnect.sh", - "list": "bash -lc 'bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js ps \"$@\"' --", + "list": "bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js ps", "dev": "bun run --cwd packages/app dev", "web:dev": "bun run --cwd packages/app dev:web", "web:build": "bun run --cwd packages/app build:web", @@ -47,7 +47,7 @@ "lint:effect": "bun run --filter @prover-coder-ai/docker-git lint:effect && bun run --filter @effect-template/lib lint:effect", "test": "bun run --filter @prover-coder-ai/docker-git-session-sync test && bun run --filter @prover-coder-ai/docker-git test && bun run --filter @effect-template/lib test", "typecheck": "bun run --filter @prover-coder-ai/docker-git-session-sync typecheck && bun run --filter @prover-coder-ai/docker-git typecheck && bun run --filter @effect-template/lib typecheck", - "start": "bash -lc 'bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js \"$@\"' --" + "start": "bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js" }, "devDependencies": { "@changesets/changelog-github": "^0.7.0", diff --git a/packages/app/package.json b/packages/app/package.json index 6e40edc0..3415cc1a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -28,12 +28,12 @@ "prebuild:docker-git": "bun install --cwd ../.. && bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", "build:docker-git": "vite build --config vite.docker-git.config.ts", "check": "bun run typecheck", - "clone": "bash -lc 'bun run build:docker-git && bun dist/src/docker-git/main.js clone \"$@\"' --", - "open": "bash -lc 'bun run build:docker-git && bun dist/src/docker-git/main.js open \"$@\"' --", - "docker-git": "bash -lc 'bun run build:docker-git && bun dist/src/docker-git/main.js \"$@\"' --", - "list": "bash -lc 'bun run build:docker-git && bun dist/src/docker-git/main.js ps \"$@\"' --", + "clone": "bun run build:docker-git && bun dist/src/docker-git/main.js clone", + "open": "bun run build:docker-git && bun dist/src/docker-git/main.js open", + "docker-git": "bun run build:docker-git && bun dist/src/docker-git/main.js", + "list": "bun run build:docker-git && bun dist/src/docker-git/main.js ps", "preview:web": "vite preview --config vite.web.config.ts", - "start": "bash -lc 'bun run build:docker-git && bun dist/src/docker-git/main.js \"$@\"' --", + "start": "bun run build:docker-git && bun dist/src/docker-git/main.js", "pretest": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", "test": "bun run lint:tests && vitest run", "pretypecheck": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", @@ -114,6 +114,7 @@ "eslint-plugin-sonarjs": "^4.0.3", "eslint-plugin-sort-destructure-keys": "^3.0.0", "eslint-plugin-unicorn": "^64.0.0", + "fast-check": "3.23.2", "globals": "^17.6.0", "jscpd": "^4.1.1", "typescript": "^6.0.3", diff --git a/packages/app/src/lib/core/templates-entrypoint/tasks.ts b/packages/app/src/lib/core/templates-entrypoint/tasks.ts index d6264455..1889eb05 100644 --- a/packages/app/src/lib/core/templates-entrypoint/tasks.ts +++ b/packages/app/src/lib/core/templates-entrypoint/tasks.ts @@ -117,6 +117,17 @@ const renderCloneAuthRepoUrl = (): string => AUTH_REPO_URL="$(printf "%s" "$REPO_URL" | sed "s#^https://#https://\${RESOLVED_GIT_AUTH_USER}:\${RESOLVED_GIT_AUTH_TOKEN}@#")" fi` +// CHANGE: restrict clone-cache mirror refresh to branch and tag refs +// WHY: broad refs include hosted forge PR refs and make cache reuse proportional to every remote ref +// QUOTE(ТЗ): "Для тестов можно реализовать CI/CD workflow для Linux, MAC, Windows" +// REF: issue-278-ci-check-clone-cache +// SOURCE: n/a +// FORMAT THEOREM: forall r in refreshedRefs: r in refs/heads/* union refs/tags/* +// PURITY: CORE +// INVARIANT: clone-cache refresh never requests refs/pull/* or refs/merge-requests/* +// COMPLEXITY: O(|heads| + |tags|) +const cloneCacheRefreshRefspecs = "'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'" + const renderCloneCacheInit = (config: TemplateConfig): string => ` CLONE_CACHE_ARGS="" CACHE_REPO_DIR="" @@ -135,7 +146,7 @@ const renderCloneCacheInit = (config: TemplateConfig): string => chown 1000:1000 "$CACHE_ROOT" || true if [[ -d "$CACHE_REPO_DIR" ]]; then if su - ${config.sshUser} -c "git --git-dir '$CACHE_REPO_DIR' rev-parse --is-bare-repository >/dev/null 2>&1"; then - if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git --git-dir '$CACHE_REPO_DIR' fetch --progress --prune '$AUTH_REPO_URL' '+refs/*:refs/*'"; then + if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git --git-dir '$CACHE_REPO_DIR' fetch --progress --prune '$AUTH_REPO_URL' ${cloneCacheRefreshRefspecs}"; then echo "[clone-cache] mirror refresh failed for $REPO_URL" fi CLONE_CACHE_ARGS="--reference-if-able '$CACHE_REPO_DIR' --dissociate" diff --git a/packages/app/tests/docker-git/actions-project-create.test.ts b/packages/app/tests/docker-git/actions-project-create.test.ts new file mode 100644 index 00000000..5032d24a --- /dev/null +++ b/packages/app/tests/docker-git/actions-project-create.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import * as fc from "fast-check" +import { beforeEach, vi } from "vitest" + +import type { CreateInputs } from "../../src/docker-git/menu-types.js" +import { submitCreateInputs } from "../../src/web/actions-project-create.js" +import type { ApiEvent, loadProjectDetails, ProjectDetails, startCreateProject } from "../../src/web/api.js" +import type { openProjectEventStream } from "../../src/web/project-events.js" +import { makeBrowserActionContext, waitForAssertion } from "./browser-action-context-fixture.js" + +const eventStreamCloseMock = vi.hoisted(() => vi.fn<() => void>()) +const loadProjectDetailsMock = vi.hoisted(() => vi.fn()) +const openProjectEventStreamMock = vi.hoisted(() => vi.fn()) +const startCreateProjectMock = vi.hoisted(() => vi.fn()) + +vi.mock("../../src/web/api.js", () => ({ + loadProjectDetails: loadProjectDetailsMock, + startCreateProject: startCreateProjectMock +})) + +vi.mock("../../src/web/project-events.js", () => ({ + openProjectEventStream: openProjectEventStreamMock +})) + +const createInputConfig = { + cpuLimit: "75%", + enableMcpPlaywright: true, + force: false, + forceEnv: false, + gpu: "none", + outDir: "/home/dev/.docker-git/octocat/Hello-World", + ramLimit: "1g", + repoRef: "main", + repoUrl: "https://github.com/octocat/Hello-World.git" +} satisfies Omit + +const createInputs: CreateInputs = { + ...createInputConfig, + runUp: true +} + +const expectedCreateDraft = { + ...createInputConfig, + up: createInputs.runUp +} + +const project = { + authorizedKeysExists: true, + authorizedKeysPath: "/home/dev/.docker-git/octocat/Hello-World/.ssh/authorized_keys", + clonedOnHostname: "runner", + codexAuthPath: "/home/dev/.docker-git/.orch/auth/codex", + codexHome: "/home/dev/.docker-git/.orch/codex", + containerName: "docker-git-octocat-hello-world", + displayName: "octocat/Hello-World", + envGlobalPath: "/home/dev/.docker-git/.orch/env/global.env", + envProjectPath: "/home/dev/.docker-git/octocat/Hello-World/.orch/env/project.env", + gpu: "none", + id: "project-1", + projectDir: "/home/dev/.docker-git/octocat/Hello-World", + projectKey: "octocat/Hello-World", + repoRef: "main", + repoUrl: "https://github.com/octocat/Hello-World.git", + serviceName: "app", + sshCommand: "ssh -p 2244 dev@127.0.0.1", + sshPort: 2244, + sshSessions: 0, + sshUser: "dev", + startedAtEpochMs: 1_777_000_000_000, + startedAtIso: "2026-05-13T00:00:00.000Z", + status: "running", + statusLabel: "running", + targetDir: "/home/dev/project" +} satisfies ProjectDetails + +const projectDetailsWithId = (projectId: string) => + ({ + ...project, + id: projectId + }) satisfies ProjectDetails + +const projectCreatedEventFor = ( + createdProject: ReturnType +): ApiEvent => ({ + at: "2026-05-13T00:00:01.000Z", + payload: { + project: createdProject, + projectId: createdProject.id + }, + projectId: createdProject.id, + seq: 8, + type: "project.created" +}) + +const readCreateEventHandler = () => { + const handler = openProjectEventStreamMock.mock.calls[0]?.[1]?.onEvent + if (handler === undefined) { + throw new Error("missing create event handler") + } + return handler +} + +const resetCreateMocks = ( + projectId = project.id, + cursor = 7 +) => { + eventStreamCloseMock.mockReset() + loadProjectDetailsMock.mockReset() + openProjectEventStreamMock.mockReset() + startCreateProjectMock.mockReset() + startCreateProjectMock.mockImplementation(() => + Effect.succeed({ + accepted: true, + cursor, + projectId + }) + ) + openProjectEventStreamMock.mockImplementation(() => ({ close: eventStreamCloseMock })) +} + +const runCreateFlow = ( + createdProject: ReturnType +) => + Effect.gen(function*(_) { + const { context, output, reloadDashboard, setMessage } = makeBrowserActionContext() + + submitCreateInputs(createInputs, context) + + yield* _(waitForAssertion(() => { + expect(openProjectEventStreamMock).toHaveBeenCalledTimes(1) + })) + readCreateEventHandler()(projectCreatedEventFor(createdProject)) + + yield* _(waitForAssertion(() => { + expect(context.setSelectedProject).toHaveBeenCalledWith(createdProject) + })) + + return { context, createdProject, output, reloadDashboard, setMessage } + }) + +const expectCreateFlowInvariants = ( + { + context, + createdProject, + cursor, + reloadDashboard + }: { + readonly context: ReturnType["context"] + readonly createdProject: ReturnType + readonly cursor: number + readonly reloadDashboard: ReturnType["reloadDashboard"] + } +) => { + expect(openProjectEventStreamMock).toHaveBeenCalledWith( + createdProject.id, + expect.objectContaining({ initialCursor: cursor }) + ) + expect(eventStreamCloseMock).toHaveBeenCalledTimes(1) + expect(loadProjectDetailsMock).not.toHaveBeenCalled() + expect(reloadDashboard).toHaveBeenCalledTimes(1) + expect(context.setSelectedProjectId).toHaveBeenCalledWith(createdProject.id) + expect(context.setSelectedProject).toHaveBeenCalledWith(createdProject) +} + +describe("browser create project action", () => { + beforeEach(() => { + resetCreateMocks() + }) + + it.effect("clones a project through the browser menu create flow", () => + Effect.gen(function*(_) { + const { context, createdProject, output, reloadDashboard, setMessage } = yield* _( + runCreateFlow(projectDetailsWithId(project.id)) + ) + + expect(startCreateProjectMock).toHaveBeenCalledWith(expectedCreateDraft) + expectCreateFlowInvariants({ context, createdProject, cursor: 7, reloadDashboard }) + expect(context.setSelectedMenuIndex).toHaveBeenCalledWith(1) + expect(setMessage).toHaveBeenLastCalledWith("Created octocat/Hello-World.") + expect(output()).toContain("[create] Project creation requested") + expect(output()).toContain("[create] Project accepted: project-1") + expect(output()).toContain("[create] Project created") + })) + + it.effect("preserves create event invariants for generated project ids and cursors", () => + Effect.tryPromise({ + catch: (error) => error, + try: () => + fc.assert( + fc.asyncProperty( + fc.uuid(), + fc.integer({ min: 0, max: 10_000 }), + (projectId, cursor) => + Effect.runPromise( + Effect.gen(function*(_) { + resetCreateMocks(projectId, cursor) + const createdProject = projectDetailsWithId(projectId) + const { context, reloadDashboard } = yield* _(runCreateFlow(createdProject)) + + expectCreateFlowInvariants({ context, createdProject, cursor, reloadDashboard }) + }) + ) + ), + { numRuns: 25 } + ) + })) +}) diff --git a/packages/app/tests/docker-git/app-ready-create.test.ts b/packages/app/tests/docker-git/app-ready-create.test.ts index 80fc7900..8a3437cf 100644 --- a/packages/app/tests/docker-git/app-ready-create.test.ts +++ b/packages/app/tests/docker-git/app-ready-create.test.ts @@ -1,20 +1,61 @@ +import * as fc from "fast-check" import type { Dispatch, SetStateAction } from "react" -import { describe, expect, it, vi } from "vitest" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { deriveRepoPathParts, resolveRepoInput } from "../../src/docker-git/frontend-lib/core/domain.js" import { type CreateFlowView, createInitialFlowView, resolveCreateFlowSteps } from "../../src/docker-git/menu-create-shared.js" +import type { CreateInputs } from "../../src/docker-git/menu-types.js" +import type { submitCreateInputs } from "../../src/web/actions-projects.js" import type { GithubAuthStatus } from "../../src/web/api.js" import { submitCreateView } from "../../src/web/app-ready-create.js" import { makeBrowserActionContext } from "./browser-action-context-fixture.js" +const submitCreateInputsMock = vi.hoisted(() => vi.fn()) + +vi.mock("../../src/web/actions-projects.js", () => ({ + submitCreateInputs: submitCreateInputsMock +})) + const validGithubStatus: GithubAuthStatus = { summary: "valid", tokens: [{ key: "default", label: "default", login: "octocat", status: "valid" }] } +const githubNameChars = "abcdefghijklmnopqrstuvwxyz0123456789-" +const githubNameCharArbitrary = fc + .integer({ min: 0, max: githubNameChars.length - 1 }) + .map((index) => githubNameChars[index] ?? "a") + +const githubSegmentArbitrary = fc + .array(githubNameCharArbitrary, { minLength: 1, maxLength: 12 }) + .map((chars) => chars.join("")) + .filter((value) => !value.startsWith("-") && !value.endsWith("-")) + +const repositoryCreateInputArbitrary = fc.record({ + branch: fc.option(githubSegmentArbitrary, { nil: null }), + owner: githubSegmentArbitrary, + repo: githubSegmentArbitrary +}).map(({ branch, owner, repo }) => ({ + expectedRepoRef: branch ?? "main", + repoUrl: branch === null + ? `https://github.com/${owner}/${repo}` + : `https://github.com/${owner}/${repo}/tree/${branch}` +})) + +const defaultQuickCreateInputs = { + cpuLimit: "", + enableMcpPlaywright: false, + force: false, + forceEnv: false, + gpu: "none", + ramLimit: "", + runUp: true +} satisfies Omit + const createSetCreateViewSpy = () => { const spy = vi.fn<(value: SetStateAction) => void>() const setCreateView: Dispatch> = spy @@ -30,22 +71,57 @@ const requireCreateViewValue = ( return value } -const submitCreateBuffer = (buffer: string) => { +const submitCreateBuffer = ( + buffer: string, + options: { readonly quickCreate?: boolean } = {} +) => { const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() + const quickCreate = options.quickCreate === undefined ? {} : { quickCreate: options.quickCreate } submitCreateView({ context, controllerCwd: "/workspace", createView: createInitialFlowView(buffer), projectsRoot: "/home/dev/.docker-git", + ...quickCreate, setCreateView }) return { context, setCreateViewSpy } } +const requireSubmittedCreateInputs = (): CreateInputs => { + const inputs = submitCreateInputsMock.mock.calls[0]?.[0] + if (inputs === undefined) { + throw new Error("Expected submitted CreateInputs.") + } + return inputs +} + +const expectQuickCreateInputs = ( + expected: Pick +) => { + expect(requireSubmittedCreateInputs()).toEqual( + { + ...defaultQuickCreateInputs, + ...expected + } satisfies CreateInputs + ) +} + +const expectCreateViewReset = (setCreateViewSpy: ReturnType["setCreateViewSpy"]) => { + expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toEqual(createInitialFlowView()) +} + +const expectedOutDirForRepoUrl = (repoUrl: string): string => + `/home/dev/.docker-git/${deriveRepoPathParts(resolveRepoInput(repoUrl).repoUrl).pathParts.join("/")}` + describe("app-ready-create", () => { + beforeEach(() => { + submitCreateInputsMock.mockReset() + }) + it("advances to the next create field on Enter for a repo URL", () => { const { context, setCreateViewSpy } = submitCreateBuffer("https://github.com/org/repo/tree/feature-x --force") @@ -77,4 +153,37 @@ describe("app-ready-create", () => { expect(setCreateViewSpy).not.toHaveBeenCalled() expect(context.setMessage).toHaveBeenCalledWith("Missing value for option: --bogus") }) + + it("submits a quick create clone from the Create menu", () => { + const { setCreateViewSpy } = submitCreateBuffer( + "https://github.com/octocat/Hello-World/tree/feature-x", + { quickCreate: true } + ) + + expect(submitCreateInputsMock).toHaveBeenCalledTimes(1) + expectQuickCreateInputs({ + outDir: "/home/dev/.docker-git/octocat/hello-world", + repoRef: "feature-x", + repoUrl: "https://github.com/octocat/Hello-World/tree/feature-x" + }) + expectCreateViewReset(setCreateViewSpy) + }) + + it("preserves quick create repo url to out dir invariants for generated GitHub repos", () => { + fc.assert( + fc.property(repositoryCreateInputArbitrary, ({ expectedRepoRef, repoUrl }) => { + submitCreateInputsMock.mockReset() + const { setCreateViewSpy } = submitCreateBuffer(repoUrl, { quickCreate: true }) + + expect(submitCreateInputsMock).toHaveBeenCalledTimes(1) + expectQuickCreateInputs({ + outDir: expectedOutDirForRepoUrl(repoUrl), + repoRef: expectedRepoRef, + repoUrl + }) + expectCreateViewReset(setCreateViewSpy) + }), + { numRuns: 50 } + ) + }) }) diff --git a/packages/app/tests/docker-git/package-scripts-cross-platform.test.ts b/packages/app/tests/docker-git/package-scripts-cross-platform.test.ts new file mode 100644 index 00000000..e73993d6 --- /dev/null +++ b/packages/app/tests/docker-git/package-scripts-cross-platform.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "@effect/vitest" +import * as fc from "fast-check" + +import rootPackage from "../../../../package.json" with { type: "json" } +import sessionSyncPackage from "../../../docker-git-session-sync/package.json" with { type: "json" } +import appPackage from "../../package.json" with { type: "json" } + +const launchScripts: ReadonlyArray> = [ + { packageName: "workspace", scriptName: "clone", script: rootPackage.scripts.clone }, + { packageName: "workspace", scriptName: "open", script: rootPackage.scripts.open }, + { packageName: "workspace", scriptName: "docker-git", script: rootPackage.scripts["docker-git"] }, + { packageName: "workspace", scriptName: "list", script: rootPackage.scripts.list }, + { packageName: "workspace", scriptName: "start", script: rootPackage.scripts.start }, + { packageName: "@prover-coder-ai/docker-git", scriptName: "clone", script: appPackage.scripts.clone }, + { packageName: "@prover-coder-ai/docker-git", scriptName: "open", script: appPackage.scripts.open }, + { + packageName: "@prover-coder-ai/docker-git", + scriptName: "docker-git", + script: appPackage.scripts["docker-git"] + }, + { packageName: "@prover-coder-ai/docker-git", scriptName: "list", script: appPackage.scripts.list }, + { packageName: "@prover-coder-ai/docker-git", scriptName: "start", script: appPackage.scripts.start } +] + +describe("package scripts cross-platform contract", () => { + it("keeps user-facing launch scripts independent from bash", () => { + fc.assert(fc.property(fc.constantFrom(...launchScripts), (entry) => { + expect(entry.script, `${entry.packageName}:${entry.scriptName}`).not.toMatch(/\bbash(?:\.exe)?\b/u) + })) + }) + + it("keeps final package build independent from raw chmod", () => { + fc.assert(fc.property(fc.constant(sessionSyncPackage.scripts.build), (script) => { + expect(script).not.toMatch(/\bchmod\s+/u) + })) + }) +}) diff --git a/packages/docker-git-session-sync/package.json b/packages/docker-git-session-sync/package.json index 1eb559d3..bf616372 100644 --- a/packages/docker-git-session-sync/package.json +++ b/packages/docker-git-session-sync/package.json @@ -10,7 +10,7 @@ "dist" ], "scripts": { - "build": "vite build && chmod +x dist/docker-git-session-sync.js", + "build": "vite build && bun ../../scripts/mark-executable.mjs dist/docker-git-session-sync.js", "check": "bun run typecheck", "prepack": "bun run build", "test": "vitest run --passWithNoTests", diff --git a/packages/lib/src/core/templates-entrypoint/tasks.ts b/packages/lib/src/core/templates-entrypoint/tasks.ts index d6264455..1889eb05 100644 --- a/packages/lib/src/core/templates-entrypoint/tasks.ts +++ b/packages/lib/src/core/templates-entrypoint/tasks.ts @@ -117,6 +117,17 @@ const renderCloneAuthRepoUrl = (): string => AUTH_REPO_URL="$(printf "%s" "$REPO_URL" | sed "s#^https://#https://\${RESOLVED_GIT_AUTH_USER}:\${RESOLVED_GIT_AUTH_TOKEN}@#")" fi` +// CHANGE: restrict clone-cache mirror refresh to branch and tag refs +// WHY: broad refs include hosted forge PR refs and make cache reuse proportional to every remote ref +// QUOTE(ТЗ): "Для тестов можно реализовать CI/CD workflow для Linux, MAC, Windows" +// REF: issue-278-ci-check-clone-cache +// SOURCE: n/a +// FORMAT THEOREM: forall r in refreshedRefs: r in refs/heads/* union refs/tags/* +// PURITY: CORE +// INVARIANT: clone-cache refresh never requests refs/pull/* or refs/merge-requests/* +// COMPLEXITY: O(|heads| + |tags|) +const cloneCacheRefreshRefspecs = "'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'" + const renderCloneCacheInit = (config: TemplateConfig): string => ` CLONE_CACHE_ARGS="" CACHE_REPO_DIR="" @@ -135,7 +146,7 @@ const renderCloneCacheInit = (config: TemplateConfig): string => chown 1000:1000 "$CACHE_ROOT" || true if [[ -d "$CACHE_REPO_DIR" ]]; then if su - ${config.sshUser} -c "git --git-dir '$CACHE_REPO_DIR' rev-parse --is-bare-repository >/dev/null 2>&1"; then - if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git --git-dir '$CACHE_REPO_DIR' fetch --progress --prune '$AUTH_REPO_URL' '+refs/*:refs/*'"; then + if ! su - ${config.sshUser} -c "GIT_TERMINAL_PROMPT=0 git --git-dir '$CACHE_REPO_DIR' fetch --progress --prune '$AUTH_REPO_URL' ${cloneCacheRefreshRefspecs}"; then echo "[clone-cache] mirror refresh failed for $REPO_URL" fi CLONE_CACHE_ARGS="--reference-if-able '$CACHE_REPO_DIR' --dissociate" diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index e4d727d0..e988469f 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -81,6 +81,19 @@ describe("renderDockerfile", () => { }) }) +describe("renderEntrypoint clone cache", () => { + it("refreshes mirrors without broad remote refs", () => { + const entrypoint = renderEntrypoint(makeTemplateConfig()) + + expect(entrypoint).toContain("git --git-dir '$CACHE_REPO_DIR' fetch") + expect(entrypoint).toContain("'+refs/heads/*:refs/heads/*'") + expect(entrypoint).toContain("'+refs/tags/*:refs/tags/*'") + expect(entrypoint).not.toContain("'+refs/*:refs/*'") + expect(entrypoint).not.toContain("'+refs/pull/*:refs/pull/*'") + expect(entrypoint).not.toContain("'+refs/merge-requests/*:refs/merge-requests/*'") + }) +}) + describe("renderEntrypointGitHooks", () => { it("installs pre-push protection checks and a global git post-push runtime", () => { const hooks = renderEntrypointGitHooks() diff --git a/scripts/final-build/browser-web-smoke.mjs b/scripts/final-build/browser-web-smoke.mjs new file mode 100644 index 00000000..85521919 --- /dev/null +++ b/scripts/final-build/browser-web-smoke.mjs @@ -0,0 +1,158 @@ +import { spawn } from "node:child_process" +import { createServer } from "node:http" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { fileURLToPath } from "node:url" + +const repoRoot = fileURLToPath(new URL("../..", import.meta.url)) +const requestTimeoutMs = 5000 +const startupTimeoutMs = 15000 + +const listen = (server) => + new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(0, "127.0.0.1", () => { + server.off("error", reject) + resolve(server.address().port) + }) + }) + +const closeServer = (server) => + new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined) { + resolve() + return + } + reject(error) + }) + }) + +const delay = (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms) + }) + +const fetchText = async (url) => { + const controller = new AbortController() + const timeout = setTimeout(() => { + controller.abort() + }, requestTimeoutMs) + try { + const response = await fetch(url, { signal: controller.signal }) + const body = await response.text() + return { body, status: response.status } + } finally { + clearTimeout(timeout) + } +} + +const waitForText = async (url, predicate) => { + const startedAt = Date.now() + let lastError = null + while (Date.now() - startedAt < startupTimeoutMs) { + try { + const response = await fetchText(url) + if (predicate(response)) { + return response + } + lastError = new Error(`Unexpected response ${response.status} from ${url}: ${response.body.slice(0, 160)}`) + } catch (error) { + lastError = error + } + await delay(250) + } + throw lastError ?? new Error(`Timed out waiting for ${url}`) +} + +const createApiServer = () => + createServer((request, response) => { + if (request.url === "/health") { + response.writeHead(200, { "content-type": "application/json; charset=utf-8" }) + response.end(JSON.stringify({ + cwd: "/tmp/docker-git-final-build-smoke", + ok: true, + projectsRoot: "/tmp/docker-git-final-build-smoke/projects", + revision: "final-build-smoke" + })) + return + } + response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }) + response.end("not found") + }) + +const waitForExit = (child) => + new Promise((resolve) => { + child.once("exit", (code, signal) => { + resolve({ code, signal }) + }) + }) + +const terminate = async (child) => { + if (child.exitCode !== null || child.signalCode !== null) { + return + } + child.kill() + const result = await Promise.race([ + waitForExit(child), + delay(3000).then(() => null) + ]) + if (result === null) { + child.kill("SIGKILL") + await waitForExit(child) + } +} + +const main = async () => { + const apiServer = createApiServer() + const apiPort = await listen(apiServer) + const webPortServer = createServer() + const webPort = await listen(webPortServer) + await closeServer(webPortServer) + + const statePath = join(tmpdir(), `docker-git-web-smoke-${process.pid}.json`) + const child = spawn(process.execPath, ["packages/app/scripts/serve-dist-web.mjs"], { + cwd: repoRoot, + env: { + ...process.env, + DOCKER_GIT_API_URL: `http://127.0.0.1:${apiPort}`, + DOCKER_GIT_WEB_HOST: "127.0.0.1", + DOCKER_GIT_WEB_PORT: String(webPort), + DOCKER_GIT_WEB_REVISION: "final-build-smoke", + DOCKER_GIT_WEB_STATE_PATH: statePath + }, + stdio: ["ignore", "pipe", "pipe"] + }) + + let stdout = "" + let stderr = "" + child.stdout.setEncoding("utf8") + child.stderr.setEncoding("utf8") + child.stdout.on("data", (chunk) => { + stdout += chunk + }) + child.stderr.on("data", (chunk) => { + stderr += chunk + }) + + try { + await waitForText( + `http://127.0.0.1:${webPort}/`, + ({ body, status }) => status === 200 && body.includes("docker-git browser") + ) + await waitForText( + `http://127.0.0.1:${webPort}/api/health`, + ({ body, status }) => status === 200 && body.includes("\"ok\":true") + ) + console.log("browser web smoke passed") + } catch (error) { + console.error(stdout) + console.error(stderr) + throw error + } finally { + await terminate(child) + await closeServer(apiServer) + } +} + +await main() diff --git a/scripts/mark-executable.mjs b/scripts/mark-executable.mjs new file mode 100644 index 00000000..d7a3befa --- /dev/null +++ b/scripts/mark-executable.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env bun + +import { chmodSync } from "node:fs" +import { resolve } from "node:path" + +// CHANGE: centralize executable-bit handling for generated CLI files. +// WHY: POSIX chmod is not available on Windows, while Linux/macOS package builds require executable bins. +// QUOTE(TZ): "run conveniently on Windows and Linux" +// REF: issue-278 +// SOURCE: n/a +// FORMAT THEOREM: forall p in Paths: platform=win32 -> no_posix_chmod(p), platform!=win32 -> executable(p) +// PURITY: SHELL +// EFFECT: filesystem metadata update +// INVARIANT: missing target argument exits non-zero; Windows builds do not invoke POSIX chmod. +// COMPLEXITY: O(1)/O(1) +const target = process.argv[2] + +if (target === undefined || target.length === 0) { + process.stderr.write("Usage: mark-executable \n") + process.exitCode = 1 +} else if (process.platform !== "win32") { + chmodSync(resolve(process.cwd(), target), 0o755) +}