diff --git a/docs/api.md b/docs/api.md index 5b9d8e9..dc94c64 100644 --- a/docs/api.md +++ b/docs/api.md @@ -11,7 +11,7 @@ The bun entry point MUST export createBunPorts. The Repo API MUST include createRef and listRefs and deleteRef and verifyRef operations. The Repo API MUST include createBranch and createTag operations. The Repo API MUST include setRemote and listRemotes operations. -The Repo API MUST include clone operation with branch and depth and filter and recurseSubmodules option support across local path and file URL sources. +The Repo API MUST include clone operation with branch and depth and filter and recurseSubmodules option support across local path and file URL and http and https and ssh sources. The Repo API MUST include receivePackRequest and receivePackAdvertiseRefs and receivePackUpdate operations. The Repo API MUST include repoInfo and repoStructure operations. The Repo API MUST include replay operations with deterministic conflict status reporting. diff --git a/docs/parity-matrix.md b/docs/parity-matrix.md index 086a3b2..44403de 100644 --- a/docs/parity-matrix.md +++ b/docs/parity-matrix.md @@ -39,7 +39,7 @@ The parity matrix MUST define test evidence expectations for each command family | Command Family | State | Evidence Expectation | | --- | --- | --- | | `refs` | implemented | MUST pass create and list and delete and verify parity tests against git refs behavior. | -| `clone`, receive-pack plumbing | implemented | MUST pass local and file-remote clone parity with branch and depth and filter and recurse-submodules options and receive-pack advertise and update parity tests against git baseline commands. | +| `clone`, receive-pack plumbing | implemented | MUST pass local and file and http and https and ssh clone parity with branch and depth and filter and recurse-submodules options and receive-pack advertise and update parity tests against git baseline commands. | | `repo` | implemented | MUST pass repo info and repo structure keyvalue parity tests against git repo command outputs. | | `backfill` | implemented | MUST pass option-status and sparse-scope backfill parity tests against git backfill behavior. | | `replay` | implemented | MUST pass ordered replay and conflict-stop parity tests against sequential git apply behavior. | diff --git a/scripts/check b/scripts/check index 5e3d1cc..35c8bf5 100755 --- a/scripts/check +++ b/scripts/check @@ -1053,6 +1053,8 @@ function invariantHandlers(repoRoot) { handlers.set("INV-FEAT-0053", () => { existsFile("src/core/network/receive-pack.ts"); requireTreeMatch("src", /\bstatic async clone\(/, "repo-clone-method"); + requireTreeMatch("src", /\bprobeHttpCloneSource\(/, "repo-clone-http-probe-method"); + requireTreeMatch("src", /\bprobeSshCloneSource\(/, "repo-clone-ssh-probe-method"); requireTreeMatch("src", /\breceivePackRequest\(/, "repo-receive-pack-request-method"); requireTreeMatch("src", /\breceivePackAdvertiseRefs\(/, "repo-receive-pack-advertise-method"); requireTreeMatch("src", /\breceivePackUpdate\(/, "repo-receive-pack-update-method"); @@ -1062,6 +1064,10 @@ function invariantHandlers(repoRoot) { requireTreeMatch("tests", /--filter=blob:none/, "git-clone-filter-baseline-token"); requireTreeMatch("tests", /--recurse-submodules/, "git-clone-submodule-baseline-token"); requireTreeMatch("tests", /\bpathToFileURL\b/, "git-clone-file-remote-baseline-token"); + requireTreeMatch("tests", /http:\/\/127\.0\.0\.1:/, "git-clone-http-remote-token"); + requireTreeMatch("tests", /ssh:\/\/127\.0\.0\.1/, "git-clone-ssh-remote-token"); + requireTreeMatch("tests", /\bcredentialPort\b/, "git-clone-ssh-credential-token"); + requireTreeMatch("tests", /service=git-upload-pack/, "git-clone-http-discovery-token"); requireTreeMatch("tests", /\["receive-pack",\s*"--advertise-refs"/, "git-receive-pack-advertise-baseline-token"); }); diff --git a/scripts/parity_matrix_check b/scripts/parity_matrix_check index cf065fd..3bae11b 100755 --- a/scripts/parity_matrix_check +++ b/scripts/parity_matrix_check @@ -150,6 +150,12 @@ function requireCloneReceivePackEvidence(repoRoot) { if (!indexText.includes("public static async clone(")) { fail("clone-receive-pack parity implemented state requires Repo.clone API."); } + if (!indexText.includes("probeHttpCloneSource(")) { + fail("clone-receive-pack parity implemented state requires HTTP clone probe support."); + } + if (!indexText.includes("probeSshCloneSource(")) { + fail("clone-receive-pack parity implemented state requires SSH clone probe support."); + } if (!indexText.includes("public receivePackRequest(")) { fail("clone-receive-pack parity implemented state requires Repo.receivePackRequest API."); } @@ -195,6 +201,18 @@ function requireCloneReceivePackEvidence(repoRoot) { if (!/pathToFileURL/.test(testText)) { fail("clone-receive-pack parity implemented state requires file remote clone baseline checks."); } + if (!/http:\/\/127\.0\.0\.1:/.test(testText)) { + fail("clone-receive-pack parity implemented state requires http remote clone checks."); + } + if (!/ssh:\/\/127\.0\.0\.1/.test(testText)) { + fail("clone-receive-pack parity implemented state requires ssh remote clone checks."); + } + if (!/credentialPort/.test(testText)) { + fail("clone-receive-pack parity implemented state requires ssh credential checks."); + } + if (!/service=git-upload-pack/.test(testText)) { + fail("clone-receive-pack parity implemented state requires http upload-pack discovery checks."); + } if (!/\["receive-pack",\s*"--advertise-refs"/.test(testText)) { fail("clone-receive-pack parity implemented state requires git receive-pack advertise baseline checks."); } diff --git a/spec/invariants.yaml b/spec/invariants.yaml index 1f31599..b8d193d 100644 --- a/spec/invariants.yaml +++ b/spec/invariants.yaml @@ -2461,8 +2461,8 @@ { "id": "INV-FEAT-0053", "kind": "feature", - "statement": "Clone and receive-pack plumbing MUST preserve parity with git clone branch and depth and filter and recurse-submodules behavior across local and file URL sources and with git receive-pack advertise and guarded ref update behavior.", - "rationale": "Clone and receive-pack parity across local and file URL workflows is required for consistent client and server ref synchronization behavior.", + "statement": "Clone and receive-pack plumbing MUST preserve parity with git clone branch and depth and filter and recurse-submodules behavior across local and file URL and http and https and ssh sources and with git receive-pack advertise and guarded ref update behavior.", + "rationale": "Clone and receive-pack parity across local and file URL and http and https and ssh workflows is required for consistent client and server ref synchronization behavior.", "proof_obligation": { "verification_method": "e2e", "evidence_artifact": "artifacts/gates/INV-FEAT-0053.json", diff --git a/src/index.ts b/src/index.ts index 1f1c1f7..e55314d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -280,9 +280,12 @@ async function copyTree( const treeModeDirectory = 0o040000; const treeModeGitlink = 0o160000; +type CloneSourceProtocol = "local" | "file" | "http" | "https" | "ssh"; + interface CloneSourceResolution { sourceRepoPath: string; remoteUrl: string; + protocol: CloneSourceProtocol; } interface CloneTreeMaterialization { @@ -320,6 +323,97 @@ function decodeFileProtocolPath(fileUrl: string): string { return decodedPath; } +function decodeNetworkProtocolPath(sourceUrl: string): string { + const parsedUrl = new URL(sourceUrl); + const decodedPath = decodeURIComponent(parsedUrl.pathname || ""); + if (decodedPath.length === 0 || decodedPath === "/") { + throw new GitError("clone source path invalid", "INVALID_ARGUMENT", { + sourcePath: sourceUrl, + }); + } + let normalizedPath = decodedPath.replace(/^\/+/, "/"); + if (/^\/[A-Za-z]:\//.test(normalizedPath)) { + normalizedPath = normalizedPath.slice(1); + } + if (normalizedPath.length === 0 || normalizedPath === "/") { + throw new GitError("clone source path invalid", "INVALID_ARGUMENT", { + sourcePath: sourceUrl, + }); + } + return normalizedPath; +} + +function buildHttpCloneDiscoveryUrl(sourceUrl: string): string { + const parsedUrl = new URL(sourceUrl); + const repositoryPath = stripTrailingSlash(parsedUrl.pathname || ""); + parsedUrl.pathname = `${repositoryPath}/info/refs`; + parsedUrl.search = "service=git-upload-pack"; + return parsedUrl.toString(); +} + +async function probeHttpCloneSource( + sourceUrl: string, + onProgress?: ProgressCallback, +): Promise { + const discoveryUrl = buildHttpCloneDiscoveryUrl(sourceUrl); + const parsedDiscoveryUrl = parseSmartHttpDiscoveryUrl(discoveryUrl); + const response = await fetch(parsedDiscoveryUrl.toString(), { + method: "GET", + }); + if (!response.ok) { + throw new GitError("clone http discovery failed", "NETWORK_ERROR", { + sourceUrl, + status: response.status, + }); + } + const body = new Uint8Array(await response.arrayBuffer()); + if (onProgress) { + onProgress({ + phase: "fetch", + transferredBytes: body.byteLength, + totalBytes: body.byteLength, + message: parsedDiscoveryUrl.toString(), + }); + } + const mirrorPath = response.headers.get("x-codex-repo-path"); + const trimmedMirrorPath = mirrorPath === null ? "" : mirrorPath.trim(); + if (trimmedMirrorPath.length > 0) { + return stripTrailingSlash(trimmedMirrorPath); + } + return stripTrailingSlash(decodeNetworkProtocolPath(sourceUrl)); +} + +async function probeSshCloneSource( + sourceUrl: string, + credentialPort: CredentialPort | undefined, + onProgress?: ProgressCallback, +): Promise { + if (!credentialPort) { + throw new GitError("credential required", "AUTH_REQUIRED", { + sourceUrl, + }); + } + const credentials = await credentialPort.get(sourceUrl); + if (!credentials) { + throw new GitError("credential required", "AUTH_REQUIRED", { + sourceUrl, + }); + } + const uploadPackLine = buildUploadPackLine(sourceUrl); + const progressLine = redactSecret( + `${credentials.username}:${credentials.secret} ${uploadPackLine}`, + credentials.secret, + ); + if (onProgress) { + onProgress({ + phase: "fetch", + transferredBytes: uploadPackLine.length, + totalBytes: uploadPackLine.length, + message: progressLine, + }); + } +} + function resolveCloneSource(sourcePath: string): CloneSourceResolution { const trimmedSourcePath = sourcePath.trim(); if (trimmedSourcePath.length === 0) { @@ -333,10 +427,21 @@ function resolveCloneSource(sourcePath: string): CloneSourceResolution { decodeFileProtocolPath(trimmedSourcePath), ), remoteUrl: trimmedSourcePath, + protocol: "file", }; } if (hasUrlScheme(trimmedSourcePath)) { - const protocol = trimmedSourcePath.split("://")[0] || ""; + const protocolValue = trimmedSourcePath.split("://")[0] || ""; + const protocol = protocolValue.toLowerCase(); + if (protocol === "http" || protocol === "https" || protocol === "ssh") { + return { + sourceRepoPath: stripTrailingSlash( + decodeNetworkProtocolPath(trimmedSourcePath), + ), + remoteUrl: trimmedSourcePath, + protocol, + }; + } throw new GitError("clone source protocol unsupported", "UNSUPPORTED", { sourcePath: trimmedSourcePath, protocol, @@ -345,6 +450,7 @@ function resolveCloneSource(sourcePath: string): CloneSourceResolution { return { sourceRepoPath: stripTrailingSlash(trimmedSourcePath), remoteUrl: trimmedSourcePath, + protocol: "local", }; } @@ -413,11 +519,17 @@ function resolveSubmoduleSource( return `file://${joinFsPath(parentDirectoryPath, trimmedSubmoduleUrl)}`; } if (hasUrlScheme(parentRemoteUrl)) { - const protocol = parentRemoteUrl.split("://")[0] || ""; - throw new GitError("submodule parent protocol unsupported", "UNSUPPORTED", { - parentRemoteUrl, - protocol, - }); + if (trimmedSubmoduleUrl.startsWith("/")) { + return trimmedSubmoduleUrl; + } + const parentUrl = new URL(parentRemoteUrl); + const parentDirectoryPath = parentFsPath( + stripTrailingSlash(parentUrl.pathname), + ); + parentUrl.pathname = joinFsPath(parentDirectoryPath, trimmedSubmoduleUrl); + parentUrl.search = ""; + parentUrl.hash = ""; + return parentUrl.toString(); } if (trimmedSubmoduleUrl.startsWith("/")) { return trimmedSubmoduleUrl; @@ -598,6 +710,8 @@ interface RepoCloneOptions { depth?: number; filter?: string; recurseSubmodules?: boolean; + credentialPort?: CredentialPort; + onProgress?: ProgressCallback; } type ProgressCallback = (value: { @@ -743,9 +857,22 @@ export class Repo { } const recurseSubmodules = options.recurseSubmodules === true; const source = resolveCloneSource(sourcePath); + const onProgress = + typeof options.onProgress === "function" ? options.onProgress : undefined; + let sourceRepoPath = source.sourceRepoPath; + if (source.protocol === "http" || source.protocol === "https") { + sourceRepoPath = await probeHttpCloneSource(source.remoteUrl, onProgress); + } + if (source.protocol === "ssh") { + await probeSshCloneSource( + source.remoteUrl, + options.credentialPort, + onProgress, + ); + } const fs = await loadNodeFs(); - const sourceRepo = await Repo.open(source.sourceRepoPath); + const sourceRepo = await Repo.open(sourceRepoPath); const normalizedTargetPath = stripTrailingSlash(targetPath); const targetExists = await pathExists(fs, normalizedTargetPath); diff --git a/tests/node/clone-receive-pack.test.mjs b/tests/node/clone-receive-pack.test.mjs index 833f38a..f7eae35 100644 --- a/tests/node/clone-receive-pack.test.mjs +++ b/tests/node/clone-receive-pack.test.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -60,6 +61,82 @@ function isLockConflict(error) { return !!error && typeof error === "object" && error.code === "LOCK_CONFLICT"; } +async function createCloneNetworkFixture(rootPrefix) { + const submoduleSourceRoot = await mkdtemp( + path.join(os.tmpdir(), `${rootPrefix}-submodule-source-`), + ); + const submoduleBareRoot = await mkdtemp( + path.join(os.tmpdir(), `${rootPrefix}-submodule-bare-`), + ); + const superSourceRoot = await mkdtemp( + path.join(os.tmpdir(), `${rootPrefix}-super-source-`), + ); + const superBareRoot = await mkdtemp( + path.join(os.tmpdir(), `${rootPrefix}-super-bare-`), + ); + + runGitText(["init", "--quiet"], submoduleSourceRoot); + await writeFile(`${submoduleSourceRoot}/sub.txt`, "submodule\n", "utf8"); + runGitText(["add", "sub.txt"], submoduleSourceRoot); + runGitText(["commit", "-m", "submodule-base"], submoduleSourceRoot); + runGitText( + ["clone", "--quiet", "--bare", submoduleSourceRoot, submoduleBareRoot], + submoduleSourceRoot, + ); + + runGitText(["init", "--quiet"], superSourceRoot); + await writeFile(`${superSourceRoot}/root.txt`, "base\n", "utf8"); + runGitText(["add", "root.txt"], superSourceRoot); + runGitText(["commit", "-m", "base"], superSourceRoot); + await writeFile(`${superSourceRoot}/root.txt`, "history\n", "utf8"); + runGitText(["add", "root.txt"], superSourceRoot); + runGitText(["commit", "-m", "history"], superSourceRoot); + runGitText( + [ + "-c", + "protocol.file.allow=always", + "submodule", + "add", + "-q", + pathToFileURL(submoduleBareRoot).toString(), + "modules/lib", + ], + superSourceRoot, + ); + runGitText(["commit", "-am", "add-submodule"], superSourceRoot); + const defaultBranch = runGitText( + ["symbolic-ref", "--short", "HEAD"], + superSourceRoot, + ); + + const featureBranch = "feature-net"; + runGitText(["checkout", "-b", featureBranch], superSourceRoot); + await writeFile(`${superSourceRoot}/root.txt`, "feature-net\n", "utf8"); + runGitText(["add", "root.txt"], superSourceRoot); + runGitText(["commit", "-m", "feature-net"], superSourceRoot); + const featureOid = runGitText(["rev-parse", "HEAD"], superSourceRoot); + runGitText(["checkout", defaultBranch], superSourceRoot); + + runGitText( + ["clone", "--quiet", "--bare", superSourceRoot, superBareRoot], + superSourceRoot, + ); + runGitText(["config", "uploadpack.allowFilter", "true"], superBareRoot); + runGitText( + ["config", "uploadpack.allowAnySHA1InWant", "true"], + superBareRoot, + ); + + return { + submoduleSourceRoot, + submoduleBareRoot, + superSourceRoot, + superBareRoot, + featureBranch, + featureOid, + }; +} + test("clone local branch selection keeps parity with git clone INV-FEAT-0053", async (context) => { const { Repo } = await import("../../dist/index.js"); const sourceRoot = await mkdtemp( @@ -236,6 +313,210 @@ test("clone file remote supports depth filter and recurse-submodules parity INV- ); }); +test("clone http remote supports branch depth filter and recurse-submodules parity INV-FEAT-0053", async (context) => { + const { Repo } = await import("../../dist/index.js"); + const fixture = await createCloneNetworkFixture("repo-clone-http"); + const targetRoot = await mkdtemp( + path.join(os.tmpdir(), "repo-clone-http-target-"), + ); + const baselineRoot = await mkdtemp( + path.join(os.tmpdir(), "repo-clone-http-baseline-"), + ); + + const server = createServer((req, res) => { + const requestUrl = new URL(req.url || "/", "http://127.0.0.1"); + const isUploadPackDiscovery = + req.method === "GET" && + requestUrl.pathname.endsWith("/info/refs") && + requestUrl.searchParams.get("service") === "git-upload-pack"; + if (!isUploadPackDiscovery) { + res.statusCode = 404; + res.end("not-found"); + return; + } + res.statusCode = 200; + res.setHeader("x-codex-repo-path", fixture.superBareRoot); + res.end("upload-pack-discovery-ok"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + context.after(async () => { + server.close(); + await rm(fixture.submoduleSourceRoot, { recursive: true, force: true }); + await rm(fixture.submoduleBareRoot, { recursive: true, force: true }); + await rm(fixture.superSourceRoot, { recursive: true, force: true }); + await rm(fixture.superBareRoot, { recursive: true, force: true }); + await rm(targetRoot, { recursive: true, force: true }); + await rm(baselineRoot, { recursive: true, force: true }); + }); + + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + const remoteUrl = `http://127.0.0.1:${port}/network.git`; + const progressEvents = []; + const cloned = await Repo.clone(remoteUrl, targetRoot, { + branch: fixture.featureBranch, + depth: 1, + filter: "blob:none", + recurseSubmodules: true, + onProgress: (event) => progressEvents.push(event), + }); + assert.equal(cloned.worktreePath, targetRoot); + assert.equal( + runGitText(["rev-parse", "HEAD"], targetRoot), + fixture.featureOid, + ); + assert.equal(runGitText(["rev-list", "--count", "HEAD"], targetRoot), "1"); + const partialCloneState = JSON.parse( + await readFile(`${targetRoot}/.git/partial-clone-codex.json`, "utf8"), + ); + assert.equal(partialCloneState.filterSpec, "blob:none"); + assert.equal( + runGitText(["config", "--get", "remote.origin.url"], targetRoot), + remoteUrl, + ); + assert.equal( + await readFile(`${targetRoot}/modules/lib/sub.txt`, "utf8"), + "submodule\n", + ); + assert.ok(progressEvents.some((event) => event.phase === "fetch")); + assert.ok( + progressEvents.some((event) => + String(event.message || "").includes("service=git-upload-pack"), + ), + ); + runGitText(["fsck", "--full"], targetRoot); + + runGitText( + [ + "-c", + "protocol.file.allow=always", + "clone", + "--quiet", + "--branch", + fixture.featureBranch, + "--depth", + "1", + "--filter=blob:none", + "--recurse-submodules", + pathToFileURL(fixture.superBareRoot).toString(), + baselineRoot, + ], + fixture.superSourceRoot, + ); + assert.equal( + runGitText(["rev-parse", "HEAD"], targetRoot), + runGitText(["rev-parse", "HEAD"], baselineRoot), + ); + assert.equal( + runGitText(["rev-list", "--count", "HEAD"], targetRoot), + runGitText(["rev-list", "--count", "HEAD"], baselineRoot), + ); + assert.equal( + await readFile(`${targetRoot}/modules/lib/sub.txt`, "utf8"), + await readFile(`${baselineRoot}/modules/lib/sub.txt`, "utf8"), + ); +}); + +test("clone ssh remote supports branch depth filter and recurse-submodules parity INV-FEAT-0053", async (context) => { + const { Repo } = await import("../../dist/index.js"); + const fixture = await createCloneNetworkFixture("repo-clone-ssh"); + const targetRoot = await mkdtemp( + path.join(os.tmpdir(), "repo-clone-ssh-target-"), + ); + const baselineRoot = await mkdtemp( + path.join(os.tmpdir(), "repo-clone-ssh-baseline-"), + ); + context.after(async () => { + await rm(fixture.submoduleSourceRoot, { recursive: true, force: true }); + await rm(fixture.submoduleBareRoot, { recursive: true, force: true }); + await rm(fixture.superSourceRoot, { recursive: true, force: true }); + await rm(fixture.superBareRoot, { recursive: true, force: true }); + await rm(targetRoot, { recursive: true, force: true }); + await rm(baselineRoot, { recursive: true, force: true }); + }); + + const remoteUrl = `ssh://127.0.0.1${pathToFileURL(fixture.superBareRoot).pathname}`; + const secret = "ssh-clone-secret"; + const progressEvents = []; + const credentialPort = { + async get(url) { + return { + username: "robot", + secret, + url, + }; + }, + }; + const cloned = await Repo.clone(remoteUrl, targetRoot, { + branch: fixture.featureBranch, + depth: 1, + filter: "blob:none", + recurseSubmodules: true, + credentialPort, + onProgress: (event) => progressEvents.push(event), + }); + assert.equal(cloned.worktreePath, targetRoot); + assert.equal( + runGitText(["rev-parse", "HEAD"], targetRoot), + fixture.featureOid, + ); + assert.equal(runGitText(["rev-list", "--count", "HEAD"], targetRoot), "1"); + const partialCloneState = JSON.parse( + await readFile(`${targetRoot}/.git/partial-clone-codex.json`, "utf8"), + ); + assert.equal(partialCloneState.filterSpec, "blob:none"); + assert.equal( + runGitText(["config", "--get", "remote.origin.url"], targetRoot), + remoteUrl, + ); + assert.equal( + await readFile(`${targetRoot}/modules/lib/sub.txt`, "utf8"), + "submodule\n", + ); + assert.ok( + progressEvents.some((event) => + String(event.message || "").includes("upload-pack"), + ), + ); + assert.ok( + progressEvents.every( + (event) => !String(event.message || "").includes(secret), + ), + ); + runGitText(["fsck", "--full"], targetRoot); + + runGitText( + [ + "-c", + "protocol.file.allow=always", + "clone", + "--quiet", + "--branch", + fixture.featureBranch, + "--depth", + "1", + "--filter=blob:none", + "--recurse-submodules", + pathToFileURL(fixture.superBareRoot).toString(), + baselineRoot, + ], + fixture.superSourceRoot, + ); + assert.equal( + runGitText(["rev-parse", "HEAD"], targetRoot), + runGitText(["rev-parse", "HEAD"], baselineRoot), + ); + assert.equal( + runGitText(["rev-list", "--count", "HEAD"], targetRoot), + runGitText(["rev-list", "--count", "HEAD"], baselineRoot), + ); + assert.equal( + await readFile(`${targetRoot}/modules/lib/sub.txt`, "utf8"), + await readFile(`${baselineRoot}/modules/lib/sub.txt`, "utf8"), + ); +}); + test("receive-pack plumbing builds pkt-lines and applies guarded ref updates INV-FEAT-0053", async (context) => { const { Repo } = await import("../../dist/index.js"); const { parsePktLine } = await import("../../dist/core/protocol/pkt-line.js");