diff --git a/misc/github-copilot-cli/Makefile b/misc/github-copilot-cli/Makefile index 9c1841342d95..944c59e1ca63 100644 --- a/misc/github-copilot-cli/Makefile +++ b/misc/github-copilot-cli/Makefile @@ -1,152 +1,231 @@ PORTNAME= github-copilot-cli DISTVERSION= 1.0.10 PORTEPOCH= 1 CATEGORIES= misc # machine-learning -DISTFILES= ${PORTNAME}-${DISTVERSION}${EXTRACT_SUFX} \ - ${NODE_HEADERS}${EXTRACT_SUFX} +DISTFILES= ${PORTNAME}-${DISTVERSION}${EXTRACT_SUFX} DIST_SUBDIR= ${PORTNAME} MAINTAINER= yuri@FreeBSD.org COMMENT= GitHub Copilot CLI brings the power of the coding agent to terminal WWW= https://github.com/github/copilot-cli ONLY_FOR_ARCHS= aarch64 amd64 ONLY_FOR_ARCHS_REASON= binaries are installed in folders with architecture encoded in them, patches are welcome to fix this limitation +FLAVORS= script binary +FLAVOR?= ${FLAVORS:[1]} +script_PKGNAMESUFFIX= +binary_PKGNAMESUFFIX= -bin +binary_COMMENT= GitHub Copilot CLI - standalone binary (no npm dependencies) +binary_PLIST= ${.CURDIR}/pkg-plist.binary + FETCH_DEPENDS= npm:www/npm \ jq:textproc/jq \ ${LOCALBASE}/share/certs/ca-root-nss.crt:security/ca_root_nss + +WRKSRC= ${WRKDIR}/copilot-${DISTVERSION} + +PACKAGE_NAME= @github/copilot + +DD= ${DISTDIR}/${DIST_SUBDIR} + +FETCH_SCRIPT= ${PORTSDIR}/Tools/scripts/npmjs-fetch-with-dependencies.sh + +.if ${FLAVOR} == script +DISTFILES+= ${NODE_HEADERS}${EXTRACT_SUFX} + BUILD_DEPENDS= npm:www/npm \ libsecret>0:security/libsecret \ vips>=8.17.2:graphics/vips RUN_DEPENDS= libsecret>0:security/libsecret \ rg:textproc/ripgrep \ vips>=8.17.2:graphics/vips USES= nodejs:run pkgconfig python:build -WRKSRC= ${WRKDIR}/copilot-${DISTVERSION} +.elif ${FLAVOR} == binary +DISTFILES+= ${NODE_HEADERS}${EXTRACT_SUFX} -PACKAGE_NAME= @github/copilot +BUILD_DEPENDS= npm:www/npm \ + libsecret>0:security/libsecret \ + vips>=8.17.2:graphics/vips -NODE_HEADERS= node-v22.19.0-headers +# The node binary is bundled inside the port binary; its shared libraries +# must still be present at runtime so they are listed here. +LIB_DEPENDS= libada.so:devel/libada \ + libbrotlidec.so:archivers/brotli \ + libcares.so:dns/c-ares \ + libgtest.so:devel/googletest \ + libhdr_histogram.so:graphics/hdr_histogram \ + libicui18n.so:devel/icu \ + libllhttp.so:www/llhttp \ + libmerve.so:devel/merve \ + libnbytes.so:www/nbytes \ + libnghttp2.so:www/libnghttp2 \ + libnghttp3.so:www/libnghttp3 \ + libngtcp2.so:net/libngtcp2 \ + libsimdjson.so:devel/simdjson \ + libsimdutf.so:converters/simdutf \ + libsqlite3.so:databases/sqlite3 \ + libuv.so:devel/libuv \ + libuvwasi.so:devel/uvwasi \ + libzstd.so:archivers/zstd +RUN_DEPENDS= libsecret>0:security/libsecret \ + vips>=8.17.2:graphics/vips -JS_ARCH= ${ARCH:S/amd64/x64/:S/aarch64/arm64/} -PLIST_SUB= JS_ARCH=${JS_ARCH} +USES= nodejs:build pkgconfig python:build -DD= ${DISTDIR}/${DIST_SUBDIR} +.endif # FLAVOR -FETCH_SCRIPT= ${PORTSDIR}/Tools/scripts/npmjs-fetch-with-dependencies.sh +NODE_HEADERS= node-v22.19.0-headers + +JS_ARCH= ${ARCH:S/amd64/x64/:S/aarch64/arm64/} DEP_MODULES= pty sharp keytar node_addon_api dep_pty_npm_name= @devm33/node-pty dep_pty_version= 1.0.9 dep_sharp_npm_name= sharp dep_sharp_version= 0.34.4 dep_keytar_npm_name= keytar dep_keytar_version= 7.9.0 dep_node_addon_api_npm_name= node-addon-api dep_node_addon_api_version= 8.5.0 .for dep in ${DEP_MODULES} DISTFILES+= ${dep:S/_/-/g}-${dep_${dep}_version}${EXTRACT_SUFX} .endfor +PLIST_SUB= JS_ARCH=${JS_ARCH} + do-fetch: - @if ! [ -f ${DD}/${PORTNAME}-${DISTVERSION}${EXTRACT_SUFX} ] || \ - ! [ -f ${DD}/${NODE_HEADERS}${EXTRACT_SUFX} ] || \ - ! [ -f ${DD}/pty-${dep_pty_version}${EXTRACT_SUFX} ] || \ - ! [ -f ${DD}/sharp-${dep_sharp_version}${EXTRACT_SUFX} ] || \ - ! [ -f ${DD}/keytar-${dep_keytar_version}${EXTRACT_SUFX} ] || \ - ! [ -f ${DD}/node-addon-api-${dep_node_addon_api_version}${EXTRACT_SUFX} ]; then \ - ${MKDIR} ${DD} && \ + @${MKDIR} ${DD} + @if ! [ -f ${DD}/${PORTNAME}-${DISTVERSION}${EXTRACT_SUFX} ]; then \ + ${ECHO} "====> Fetching ${PORTNAME}-${DISTVERSION}${EXTRACT_SUFX}" && \ + ${SETENV} TMPDIR=${WRKDIR} LOCALBASE=${LOCALBASE} ${FETCH_SCRIPT} \ + ${PACKAGE_NAME} ${DISTVERSION} \ + ${FILESDIR}/package-lock.json \ + ${DD}/${PORTNAME}-${DISTVERSION}${EXTRACT_SUFX}; \ + fi + @if ! [ -f ${DD}/${NODE_HEADERS}${EXTRACT_SUFX} ]; then \ ${ECHO} "====> Fetching ${NODE_HEADERS}${EXTRACT_SUFX}" && \ - ${FETCH_CMD} -q https://nodejs.org/download/release/v22.19.0/${NODE_HEADERS}${EXTRACT_SUFX} -o ${DD}/${NODE_HEADERS}${EXTRACT_SUFX} && \ + ${FETCH_CMD} -q https://nodejs.org/download/release/v22.19.0/${NODE_HEADERS}${EXTRACT_SUFX} \ + -o ${DD}/${NODE_HEADERS}${EXTRACT_SUFX}; \ + fi + @if ! [ -f ${DD}/pty-${dep_pty_version}${EXTRACT_SUFX} ]; then \ ${ECHO} "====> Fetching dependency pty" && \ ${SETENV} TMPDIR=${WRKDIR} LOCALBASE=${LOCALBASE} ${FETCH_SCRIPT} \ ${dep_pty_npm_name} ${dep_pty_version} \ ${FILESDIR}/package-lock-pty.json \ - ${DD}/pty-${dep_pty_version}${EXTRACT_SUFX} && \ + ${DD}/pty-${dep_pty_version}${EXTRACT_SUFX}; \ + fi + @if ! [ -f ${DD}/sharp-${dep_sharp_version}${EXTRACT_SUFX} ]; then \ ${ECHO} "====> Fetching dependency sharp" && \ ${SETENV} TMPDIR=${WRKDIR} LOCALBASE=${LOCALBASE} ${FETCH_SCRIPT} \ ${dep_sharp_npm_name} ${dep_sharp_version} \ ${FILESDIR}/package-lock-sharp.json \ - ${DD}/sharp-${dep_sharp_version}${EXTRACT_SUFX} && \ + ${DD}/sharp-${dep_sharp_version}${EXTRACT_SUFX}; \ + fi + @if ! [ -f ${DD}/keytar-${dep_keytar_version}${EXTRACT_SUFX} ]; then \ ${ECHO} "====> Fetching dependency keytar" && \ ${SETENV} TMPDIR=${WRKDIR} LOCALBASE=${LOCALBASE} ${FETCH_SCRIPT} \ ${dep_keytar_npm_name} ${dep_keytar_version} \ ${FILESDIR}/package-lock-keytar.json \ - ${DD}/keytar-${dep_keytar_version}${EXTRACT_SUFX} && \ + ${DD}/keytar-${dep_keytar_version}${EXTRACT_SUFX}; \ + fi + @if ! [ -f ${DD}/node-addon-api-${dep_node_addon_api_version}${EXTRACT_SUFX} ]; then \ ${ECHO} "====> Fetching dependency node-addon-api" && \ ${SETENV} TMPDIR=${WRKDIR} LOCALBASE=${LOCALBASE} ${FETCH_SCRIPT} \ ${dep_node_addon_api_npm_name} ${dep_node_addon_api_version} \ ${FILESDIR}/package-lock-node-addon-api.json \ - ${DD}/node-addon-api-${dep_node_addon_api_version}${EXTRACT_SUFX} && \ - ${ECHO} "====> Fetching ${PORTNAME}-${DISTVERSION}${EXTRACT_SUFX}" && \ - ${SETENV} TMPDIR=${WRKDIR} LOCALBASE=${LOCALBASE} ${FETCH_SCRIPT} \ - ${PACKAGE_NAME} ${DISTVERSION} \ - ${FILESDIR}/package-lock.json \ - ${DD}/${PORTNAME}-${DISTVERSION}${EXTRACT_SUFX}; \ + ${DD}/node-addon-api-${dep_node_addon_api_version}${EXTRACT_SUFX}; \ fi post-extract: # Extract node-addon-api and install into sharp/node_modules # the tarball has a nested structure, so we need to move the inner directory @${MV} \ ${WRKDIR}/${dep_node_addon_api_npm_name}-${dep_node_addon_api_version}/node_modules/${dep_node_addon_api_npm_name} \ ${WRKDIR}/${dep_sharp_npm_name}-${dep_sharp_version}/node_modules/${dep_sharp_npm_name}/node_modules/node-addon-api post-patch: # set version ${REINPLACE_CMD} -i '' \ -e 's|qg.default.createElement(U,{color:e.MUTED},"v",t)|qg.default.createElement(U,{color:e.MUTED},"v","${PORTVERSION}")|g' \ ${WRKSRC}/node_modules/@github/copilot/index.js do-build: # Create directory for FreeBSD prebuilds @${MKDIR} ${WRKSRC}/node_modules/${PACKAGE_NAME}/prebuilds/freebsd-x64 @${ECHO_MSG} "====> Building pty..." @cd ${WRKDIR}/node-pty-${dep_pty_version}/node_modules/${dep_pty_npm_name} && \ ${SETENV} HOME=${WRKDIR} CFLAGS="-I${LOCALBASE}/include" CXXFLAGS="-I${LOCALBASE}/include" \ npm rebuild --nodedir=${LOCALBASE} && \ ${CP} build/Release/pty.node ${WRKSRC}/node_modules/${PACKAGE_NAME}/prebuilds/freebsd-x64/ @${ECHO_MSG} "====> Building sharp..." @cd ${WRKDIR}/sharp-${dep_sharp_version}/node_modules/${dep_sharp_npm_name}/src && \ ${SETENV} HOME=${WRKDIR} PYTHON=${PYTHON_CMD} CXXFLAGS="-I${LOCALBASE}/include" \ node-gyp configure build --nodedir=${WRKDIR}/node-v22.19.0 && \ ${MKDIR} ${WRKSRC}/node_modules/@img/sharp-freebsd-x64 && \ ${CP} build/Release/sharp-freebsd-x64.node ${WRKSRC}/node_modules/@img/sharp-freebsd-x64/sharp.node @${ECHO_MSG} "====> Building keytar..." @cd ${WRKDIR}/keytar-${dep_keytar_version}/node_modules/${dep_keytar_npm_name} && \ ${SETENV} HOME=${WRKDIR} CFLAGS="-I${LOCALBASE}/include" CXXFLAGS="-I${LOCALBASE}/include" \ npm rebuild --nodedir=${LOCALBASE} && \ ${CP} build/Release/keytar.node ${WRKSRC}/node_modules/${PACKAGE_NAME}/prebuilds/freebsd-x64/ +.if ${FLAVOR} == binary + @${ECHO_MSG} "====> Creating copilot bundle (includes node runtime)..." + # Copy @img/sharp-freebsd-x64 into copilot's node_modules so it is findable + # after extraction to the cache dir (node resolves it relative to app.js) + @${MKDIR} ${WRKSRC}/node_modules/${PACKAGE_NAME}/node_modules/@img + @${CP} -r ${WRKSRC}/node_modules/@img/sharp-freebsd-x64 \ + ${WRKSRC}/node_modules/${PACKAGE_NAME}/node_modules/@img/ + # Embed the node runtime so it runs without a system node installation + @${CP} ${LOCALBASE}/bin/node ${WRKSRC}/node_modules/${PACKAGE_NAME}/node + @cd ${WRKSRC}/node_modules/${PACKAGE_NAME} && \ + ${TAR} --exclude=./ripgrep --exclude=./sharp \ + --exclude=./changelog.json --exclude=./npm-loader.js.orig \ + -cJf ${WRKSRC}/copilot_bundle.txz . + @${ECHO_MSG} "====> Building copilot launcher..." + @${PRINTF} '\t.global _binary_copilot_bundle_txz_start\n\t.global _binary_copilot_bundle_txz_end\n_binary_copilot_bundle_txz_start:\n\t.incbin "%s"\n_binary_copilot_bundle_txz_end:\n' \ + "${WRKSRC}/copilot_bundle.txz" > ${WRKSRC}/blob.s + @${CC} -c ${WRKSRC}/blob.s -o ${WRKSRC}/blob.o + @${CC} ${CFLAGS} \ + -DPREFIX='"${PREFIX}"' \ + -DPORTVERSION='"${PORTVERSION}"' \ + -c ${FILESDIR}/launcher.c -o ${WRKSRC}/launcher.o + @${CC} ${LDFLAGS} -o ${WRKSRC}/copilot ${WRKSRC}/launcher.o ${WRKSRC}/blob.o + @${STRIP_CMD} ${WRKSRC}/copilot +.endif # FLAVOR == binary do-install: +.if ${FLAVOR} == script # install files cd ${WRKSRC} && \ ${COPYTREE_SHARE} . ${STAGEDIR}${PREFIX}/lib # remove *.node files for other OSes @${FIND} ${STAGEDIR}${PREFIX}/lib/node_modules/${PACKAGE_NAME} -name "*\\.node" | \ ${GREP} -v freebsd | \ ${XARGS} ${RM} # remove files for other OSes @${FIND} ${STAGEDIR}${PREFIX}/lib/node_modules/${PACKAGE_NAME} -name "*linux*" | ${XARGS} ${RM} -r @${FIND} ${STAGEDIR}${PREFIX}/lib/node_modules/${PACKAGE_NAME} -name "*win32*" | ${XARGS} ${RM} -r @${FIND} ${STAGEDIR}${PREFIX}/lib/node_modules/${PACKAGE_NAME} -name "*darwin*" | ${XARGS} ${RM} -r # remove unnecessary files @${FIND} ${STAGEDIR}${PREFIX}/lib -type f -and -name "*package*.json" -delete @${FIND} ${STAGEDIR}${PREFIX}/lib -type f -and -name "README.md" -delete @${FIND} ${STAGEDIR}${PREFIX}/lib -type f -and -name "LICENSE.md" -delete # update shebang to use system node @${REINPLACE_CMD} -i '' \ -e "s|#!/usr/bin/env node|#!${PREFIX}/bin/node|" \ ${STAGEDIR}${PREFIX}/lib/node_modules/${PACKAGE_NAME}/index.js # set exec bit @${CHMOD} +x ${STAGEDIR}${PREFIX}/lib/node_modules/${PACKAGE_NAME}/npm-loader.js # create symlink in bin @${RLN} -s ${STAGEDIR}${PREFIX}/lib/node_modules/.bin/copilot ${STAGEDIR}${PREFIX}/bin/copilot # strip binaries @${FIND} ${STAGEDIR}${PREFIX}/lib/node_modules/${PACKAGE_NAME} -path "*/build/*" -name *.node | ${XARGS} ${STRIP_CMD} +.elif ${FLAVOR} == binary + ${INSTALL_PROGRAM} ${WRKSRC}/copilot ${STAGEDIR}${PREFIX}/bin/copilot +.endif # FLAVOR .include diff --git a/misc/github-copilot-cli/files/launcher.c b/misc/github-copilot-cli/files/launcher.c new file mode 100644 index 000000000000..ee3104f1a5cd --- /dev/null +++ b/misc/github-copilot-cli/files/launcher.c @@ -0,0 +1,187 @@ +/* + * launcher.c - GitHub Copilot CLI binary flavor launcher + * + * Extracts the bundled copilot JS files and node runtime to a version-specific + * cache directory and runs them. The bundle (xz-compressed tar) contains the + * full node binary so no system node installation is required. + * + * Node.js SEA (Single Executable Application) cannot be used because + * postject_find_resource() has no FreeBSD code path. + */ + +#include +#include +#include +#include +#include +#include +#include + +#ifndef PORTVERSION +#define PORTVERSION "unknown" +#endif + +#define CACHE_SUBDIR "github-copilot-cli/v" PORTVERSION + +extern const char _binary_copilot_bundle_txz_start[]; +extern const char _binary_copilot_bundle_txz_end[]; + +static int +makedirs(const char *path) +{ + char buf[1024]; + char *p; + struct stat st; + + if (stat(path, &st) == 0) + return (0); + + snprintf(buf, sizeof(buf), "%s", path); + for (p = buf + 1; *p != '\0'; p++) { + if (*p == '/') { + *p = '\0'; + if (stat(buf, &st) != 0 && mkdir(buf, 0755) != 0 && + errno != EEXIST) + return (-1); + *p = '/'; + } + } + return (mkdir(buf, 0755) == 0 || errno == EEXIST ? 0 : -1); +} + +static int +extract_bundle(const char *destdir) +{ + char tmpdir[1024], archpath[1024]; + const char *data; + size_t size; + FILE *f; + pid_t pid; + int status; + + /* Write bundle to a temp archive file */ + snprintf(archpath, sizeof(archpath), "%s/.bundle.txz.tmp", destdir); + data = _binary_copilot_bundle_txz_start; + size = (size_t)(_binary_copilot_bundle_txz_end - + _binary_copilot_bundle_txz_start); + + f = fopen(archpath, "wb"); + if (f == NULL) { + fprintf(stderr, "copilot: cannot write bundle: %s\n", + strerror(errno)); + return (-1); + } + if (fwrite(data, 1, size, f) != size) { + fclose(f); + unlink(archpath); + fprintf(stderr, "copilot: bundle write failed\n"); + return (-1); + } + fclose(f); + + /* Extract into a temp subdir, then atomically rename */ + snprintf(tmpdir, sizeof(tmpdir), "%s.extracting", destdir); + makedirs(tmpdir); + + pid = fork(); + if (pid < 0) { + unlink(archpath); + return (-1); + } + if (pid == 0) { + execl("/usr/bin/tar", "tar", "-xJf", archpath, "-C", tmpdir, + (char *)NULL); + _exit(127); + } + waitpid(pid, &status, 0); + unlink(archpath); + + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + fprintf(stderr, "copilot: bundle extraction failed\n"); + /* cleanup tmpdir */ + pid = fork(); + if (pid == 0) { + execl("/bin/rm", "rm", "-rf", tmpdir, (char *)NULL); + _exit(1); + } + if (pid > 0) + waitpid(pid, NULL, 0); + return (-1); + } + + /* Atomic rename: tmpdir -> destdir */ + if (rename(tmpdir, destdir) != 0) { + /* Another process may have already created destdir - that's ok */ + if (errno != EEXIST && errno != ENOTEMPTY) { + fprintf(stderr, "copilot: rename failed: %s\n", + strerror(errno)); + return (-1); + } + /* Use existing destdir; clean up tmpdir */ + pid = fork(); + if (pid == 0) { + execl("/bin/rm", "rm", "-rf", tmpdir, (char *)NULL); + _exit(1); + } + if (pid > 0) + waitpid(pid, NULL, 0); + } + + return (0); +} + +int +main(int argc, char *argv[]) +{ + const char *xdg, *home; + char cache_dir[1024], node_path[1080], script_path[1080]; + char **new_argv; + struct stat st; + int i; + + /* Determine cache directory */ + xdg = getenv("XDG_CACHE_HOME"); + home = getenv("HOME"); + if (xdg != NULL && xdg[0] != '\0') + snprintf(cache_dir, sizeof(cache_dir), "%s/" CACHE_SUBDIR, xdg); + else if (home != NULL && home[0] != '\0') + snprintf(cache_dir, sizeof(cache_dir), + "%s/.cache/" CACHE_SUBDIR, home); + else { + fprintf(stderr, + "copilot: HOME or XDG_CACHE_HOME must be set\n"); + return (1); + } + + snprintf(node_path, sizeof(node_path), "%s/node", cache_dir); + snprintf(script_path, sizeof(script_path), "%s/index.js", cache_dir); + + /* Extract bundle if not already done (check for node binary) */ + if (stat(node_path, &st) != 0) { + if (makedirs(cache_dir) != 0) { + fprintf(stderr, "copilot: cannot create %s: %s\n", + cache_dir, strerror(errno)); + return (1); + } + if (extract_bundle(cache_dir) != 0) + return (1); + } + + /* Build argument vector for the bundled node */ + new_argv = malloc((size_t)(argc + 2) * sizeof(char *)); + if (new_argv == NULL) { + fprintf(stderr, "copilot: malloc failed\n"); + return (1); + } + new_argv[0] = node_path; + new_argv[1] = script_path; + for (i = 1; i < argc; i++) + new_argv[i + 1] = argv[i]; + new_argv[argc + 1] = NULL; + + execv(node_path, new_argv); + + fprintf(stderr, "copilot: cannot exec %s: %s\n", node_path, + strerror(errno)); + return (1); +} diff --git a/misc/github-copilot-cli/pkg-plist.binary b/misc/github-copilot-cli/pkg-plist.binary new file mode 100644 index 000000000000..a2a43e65b9fc --- /dev/null +++ b/misc/github-copilot-cli/pkg-plist.binary @@ -0,0 +1 @@ +bin/copilot