From bddb4a8ddbb48c7c4cb74686d386607b89578677 Mon Sep 17 00:00:00 2001 From: andrewmuratov Date: Sun, 27 Sep 2026 13:31:39 -0400 Subject: [PATCH] feat: release a public multi-university Gapwise CLI --- .github/dependabot.yml | 10 +++ .github/workflows/ci.yml | 9 +- .github/workflows/release.yml | 49 +++++++++++ README.md | 55 +++++++----- RELEASING.md | 35 ++++++++ bin/gapwise.mjs | 22 ++++- bin/public-api.mjs | 154 ++++++++++++++++++++++++++++++++++ package.json | 19 +++-- scripts/verify-package.mjs | 37 ++++++++ tests/cli.test.mjs | 10 +++ tests/public-api.test.mjs | 110 ++++++++++++++++++++++++ 11 files changed, 475 insertions(+), 35 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/release.yml create mode 100644 RELEASING.md create mode 100644 bin/public-api.mjs create mode 100644 scripts/verify-package.mjs create mode 100644 tests/public-api.test.mjs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a5e7e5f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + - package-ecosystem: npm + directory: / + schedule: + interval: monthly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0fa17a9..1bb780e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,11 +11,14 @@ permissions: jobs: test: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ['22', '24'] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: '24' - - run: npm test - - run: npm pack --dry-run + node-version: ${{ matrix.node }} + - run: npm run check - run: git diff --check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7fb98c7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,49 @@ +name: Release + +on: + push: + tags: ['v*'] + +permissions: + contents: write + id-token: write + +jobs: + npm-and-github: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: '24' + registry-url: https://registry.npmjs.org + - name: Verify tag and main ancestry + run: | + test "v$(node -p 'require("./package.json").version')" = "$GITHUB_REF_NAME" + git fetch origin main + git merge-base --is-ancestor "$GITHUB_SHA" origin/main + - run: npm run check + - name: Publish (or confirm first version was bootstrapped by its owner) + run: | + version=$(node -p 'require("./package.json").version') + if npm view "@gapwise/cli@$version" version --json >/dev/null 2>&1; then + echo "@gapwise/cli@$version already exists; verifying registry install." + else + npm publish --access public + fi + - name: Verify clean registry install + run: | + version=$(node -p 'require("./package.json").version') + local_integrity=$(npm pack --json | node -e 'let s="";process.stdin.on("data",c=>s+=c);process.stdin.on("end",()=>console.log(JSON.parse(s)[0].integrity))') + registry_integrity=$(npm view "@gapwise/cli@$version" dist.integrity) + test "$local_integrity" = "$registry_integrity" + prefix=$(mktemp -d) + npm install --global --prefix "$prefix" "@gapwise/cli@$version" --ignore-scripts --no-audit --no-fund + test "$("$prefix/bin/gapwise" --version)" = "$version" + "$prefix/bin/gapwise" --help + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes --title "Gapwise CLI $GITHUB_REF_NAME" diff --git a/README.md b/README.md index 766aab1..210c9f2 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,48 @@
-Gapwise deer mark +Gapwise deer mark # Gapwise CLI -### Repeatable university integrations for one Gapwise product. +Explore supported universities and campus data, or scaffold a new Gapwise integration. [![MIT](https://img.shields.io/badge/License-MIT-111111?style=for-the-badge)](LICENSE) [![CI](https://github.com/GapwiseHQ/cli/actions/workflows/ci.yml/badge.svg)](https://github.com/GapwiseHQ/cli/actions/workflows/ci.yml)
-## Purpose +The official CLI uses the [Gapwise public API](https://api.gapwise.ca/v1) for campus discovery, buildings, places, residences, and routes. Its maintainer commands work with sibling [`gapwise`](https://github.com/GapwiseHQ/gapwise) and [`data`](https://github.com/GapwiseHQ/data) checkouts. Gapwise supports 11 Canadian universities and 13 campus models; routing and place coverage vary by campus. No university affiliation is implied. -The CLI scaffolds a university manifest entry, timetable adapter, test, and empty campus data files in the sibling [`gapwise`](https://github.com/GapwiseHQ/gapwise) and [`data`](https://github.com/GapwiseHQ/data) repositories. It also creates the app's empty campus mirror and map catalog so the generic campus loader discovers the new ID. It never copies application screens. Generated data contains no invented buildings, entrances, or routes. +## Install -## Installation - -Requires Node 24, Bun 1.3.14 for application tests, and sibling `gapwise` and `data` checkouts. The canonical CLI source is [GapwiseHQ/cli](https://github.com/GapwiseHQ/cli). Install directly from that repository: +Requires **Node.js 22 or newer**. [npm package: `@gapwise/cli`](https://www.npmjs.com/package/@gapwise/cli) is the intended canonical registry identity. Until the first registry release is verified, install from the [public source repository](https://github.com/GapwiseHQ/cli): ```sh npm install -g github:GapwiseHQ/cli -gapwise university create example-university --dry-run +gapwise --version +gapwise --help +``` + +After the registry release, install or upgrade from npm with `npm install -g @gapwise/cli`. To upgrade a GitHub installation, rerun `npm install -g github:GapwiseHQ/cli`. To uninstall either installation, run `npm uninstall -g @gapwise/cli`. + +## Explore the public platform + +```sh +gapwise universities +gapwise campuses --university uoft +gapwise campuses --university york +gapwise buildings --university carleton --query library +gapwise residences --university tmu +gapwise places --university uoft --campus utm --kind study +gapwise route --university carleton --from TB --to ML +gapwise buildings --university york --category residence --json ``` -To work on the CLI itself, clone this repository and run `npm test` or `node bin/gapwise.mjs`. By default, commands expect `cli`, `gapwise`, and `data` as sibling directories. Set `GAPWISE_WORKSPACE` or pass `--workspace /path/to/workspace` to point to their parent directory. +`--json` prints the full public API `{ data, meta }` response to stdout. Human output uses tab-separated columns for discovery and lists. Errors go to stderr and return a nonzero exit code. Campus queries require `--university`; when `--campus` is omitted, the selected university's default campus is used. For pagination, advanced filtering, and stable application integration, use the [API](https://docs.gapwise.ca/api/) or official [SDKs](https://docs.gapwise.ca/sdk/javascript/) directly. The CLI does not bypass API coverage or access private student data. + +## Integrate a new university -## Usage +Clone `cli`, `gapwise`, and `data` as sibling repositories, or pass their parent directory with `--workspace` (also available as `GAPWISE_WORKSPACE`). Maintainer validation and development need the runtimes required by those repositories, including Bun for the app tests. ```sh gapwise university create example-university --name "Example University" --short-name Example --dry-run @@ -37,25 +53,18 @@ gapwise data validate carleton gapwise data osm tmu --bbox=-79.39,43.65,-79.37,43.67 ``` -`university create --dry-run` lists its changes without writing. A new integration starts with `status: scaffold`, an adapter that throws, an unfinished test, and empty campus data. Review the manifest, implement the adapter, add permitted source backed data, replace the test, and change the status before validation. `university dev` prints the local URL with `?university=` and starts the canonical app. - -`data osm` saves an **unreviewed candidate** from an explicit OpenStreetMap bounding box. Use `--input extract.osm` for a reproducible local XML extract and `--output path.json` to choose the output. It does not promote candidate paths or entrances into routable campus data. Review identities, rights, entrances, and connectivity before editing the canonical snapshot. - -## Architecture - -`gapwise/universities.json` is the deployment and tooling manifest. `gapwise/src/universities/` holds timetable adapters. `data/universities/` holds validated campus snapshots and provenance. Run `bun scripts/sync-campus-data.ts --write` in `gapwise` after changing canonical data; the campus loader discovers generated catalogs. Shared screens and route UI remain in `gapwise/src/components` and `gapwise/src/features`. +`university create --dry-run` lists its changes without writing. A new integration starts as a scaffold with an unimplemented adapter and empty campus data. Review the manifest, implement the adapter, add permitted source-backed data, replace the placeholder test, and change the status before validation. No application UI is copied or invented campus facts added. `data osm` saves an **unreviewed candidate** from an explicit OpenStreetMap bounding box; use `--input extract.osm` for a reproducible local XML extract. Candidate paths and entrances are not automatically promoted into routable data. -## Development +## Development and release ```sh -npm test -node bin/gapwise.mjs university create example-university --dry-run +npm run check ``` -## Contributing +`check` runs unit tests, packs the npm artifact, checks its contents and size, performs a clean global install, and executes the installed binary. CI runs it on Node 22 and 24. [Release guidance](RELEASING.md) describes versioning, the initial npm owner bootstrap, and subsequent OIDC Trusted Publishing with provenance. The CLI has no runtime dependencies. -Use a focused PR, cite data sources and their redistribution terms, and keep unknown access facts unknown. See [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), and the [Gapwise documentation](https://docs.gapwise.ca). +Read the [CLI guide](https://docs.gapwise.ca/cli/), [developer documentation](https://docs.gapwise.ca/), [contribution guide](CONTRIBUTING.md), and [security policy](SECURITY.md). Gapwise itself is at [gapwise.ca](https://gapwise.ca). ## License -CLI code is MIT licensed. Campus datasets retain their source specific rights and attribution. +CLI code is [MIT licensed](LICENSE). Campus datasets retain their source-specific rights and attribution. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..b00a658 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,35 @@ +# Releasing Gapwise CLI + +`@gapwise/cli` is the intended public npm identity. The canonical source is [GapwiseHQ/cli](https://github.com/GapwiseHQ/cli). A release is the same version in `package.json`, the npm registry, the `vX.Y.Z` Git tag, and GitHub Releases. + +## Prepare a release + +1. Make changes on a feature branch, run `npm run check`, and merge a green PR to `main` without bypassing protection. +2. Update `package.json` with the intended SemVer version in the PR. Describe user-visible changes in the PR. +3. Verify `npm pack --dry-run` and a clean install of the artifact; `npm run check` does both. +4. Check that the npm version is unused before creating the tag. + +The tag workflow tests on Node 24, verifies the tag matches `package.json` and points to a commit on `main`, publishes via npm Trusted Publishing if the version does not already exist, independently installs the registry package, and creates a GitHub Release. CI also tests Node 22. The workflow never stores a publish token. npm supplies provenance automatically for a public package published from the public GitHub repository through its trusted publisher. + +## First npm publication + +The first release requires an account that can publish under `@gapwise`; this repository cannot grant npm ownership. The current agent environment has no authenticated npm account. From a clean checkout of merged `main`, the npm owner should: + +```sh +npm login +npm whoami +npm run check +npm publish --access public +``` + +The intended first version is `0.2.0`; check `package.json` before publishing and do not publish from an old checkout. If npm reports scope ownership, organization, billing, or OTP requirements, resolve those requirements in npm. Do not publish under a personal scope as a substitute. After the first release appears on the registry, add an npm Trusted Publisher in the `@gapwise/cli` package settings: + +- Provider: GitHub Actions +- Organization/user: `GapwiseHQ` +- Repository: `cli` +- Workflow filename: `release.yml` +- Allowed action: direct `npm publish` + +Then create and push an annotated `v0.2.0` tag on the same merged `main` commit. The workflow checks the existing registry version, verifies its install, and creates the corresponding GitHub Release. Subsequent new versions publish from the tag workflow through OIDC and get npm provenance. Keep the initial manual publish distinct from provenance-bearing OIDC releases; do not claim provenance for the manual first release. + +If an npm owner authorizes this workspace with an interactive login, the maintainer can complete the first publish here and perform the external verification. Never commit auth tokens or `.npmrc` credentials. diff --git a/bin/gapwise.mjs b/bin/gapwise.mjs index 6d5204d..985ec6e 100755 --- a/bin/gapwise.mjs +++ b/bin/gapwise.mjs @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; +import { help, publicCommand } from './public-api.mjs'; const scriptDir = dirname(fileURLToPath(import.meta.url)); const defaultWorkspace = resolve(scriptDir, '../..'); @@ -36,6 +37,8 @@ export function universityPlan(id, workspace, args = []) { if (manifest.universities.some((entry) => entry.id === id)) throw new Error(`${id} is already registered.`); const name = option(args, '--name') ?? (id.length <= 4 ? id.toUpperCase() : id.replace(/(^|-)[a-z]/g, (match) => match.replace('-', ' ').toUpperCase())); const shortName = option(args, '--short-name') ?? name; + if (![name, shortName].every((value) => value.trim() && !/[\x00-\x1f\x7f]/.test(value) && !value.includes('*/'))) + throw new Error('University name and short name must be single-line text.'); const host = option(args, '--host') ?? `${id}.gapwise.ca`; if (!/^[a-z0-9.-]+$/.test(host) || manifest.universities.some((entry) => entry.hosts.includes(host))) throw new Error('Host must be a unique lowercase DNS hostname.'); @@ -58,7 +61,7 @@ export function universityPlan(id, workspace, args = []) { dataPaths: [`universities/${id}/campus.json`], status: 'scaffold', }); const pascalName = id.replace(/(^|-)[a-z]/g, (match) => match.replace('-', '').toUpperCase()); - const adapter = `import type { ParsedTimetable, Meeting } from '@/lib/timetable-types';\n\n/** Implement ${name} ingestion and normalize every meeting into ParsedTimetable. */\nexport async function parseTimetable(_text: string): Promise {\n throw new Error('${name} timetable ingestion has not been implemented.');\n}\n\nexport async function load${pascalName}DemoTimetable(): Promise {\n return [];\n}\n`; + const adapter = `import type { ParsedTimetable, Meeting } from '@/lib/timetable-types';\n\n/** Implement ${name} ingestion and normalize every meeting into ParsedTimetable. */\nexport async function parseTimetable(_text: string): Promise {\n throw new Error(${JSON.stringify(`${name} timetable ingestion has not been implemented.`)});\n}\n\nexport async function load${pascalName}DemoTimetable(): Promise {\n return [];\n}\n`; const adapterTest = `import { test } from 'bun:test';\n\ntest('${id} timetable adapter normalizes a real source fixture', () => {\n throw new Error('Add a permitted timetable fixture and verify canonical meeting output.');\n});\n`; const demoTimetable = `import type { Meeting } from '../common/model';\n\nexport const DEMO_${id.toUpperCase().replaceAll('-', '_')}_MEETINGS: Meeting[] = [];\n`; const sourcesDoc = `# ${name} Data Sources and Verification\n\n- Institution: ${name} (${id})\n- Date: ${new Date().toISOString().slice(0, 10)}\n- Verification: Pending review\n\n## Sources\n\n1. Official campus directory and open datasets\n2. OpenStreetMap campus elements\n`; @@ -126,6 +129,17 @@ function validate(id, workspace) { } export function main(argv = process.argv.slice(2)) { + if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) { + console.log(help); + return; + } + if (argv.length === 1 && (argv[0] === '--version' || argv[0] === '-v')) { + console.log(readJson(join(scriptDir, '../package.json')).version); + return; + } + if (['universities', 'campuses', 'buildings', 'residences', 'places', 'route'].includes(argv[0])) { + return publicCommand(argv); + } const workspace = resolve(option(argv, '--workspace') ?? process.env.GAPWISE_WORKSPACE ?? defaultWorkspace); const [area, action, id] = argv; if (area === 'university' && action === 'create' && id) return create(id, workspace, argv); @@ -144,6 +158,7 @@ export function main(argv = process.argv.slice(2)) { return; } if (area === 'data' && action === 'validate' && id) { + if (!validId.test(id)) throw new Error('Invalid university ID.'); run('node', ['scripts/validate-university-data.mjs', id], join(workspace, 'data')); return; } @@ -160,5 +175,8 @@ export function main(argv = process.argv.slice(2)) { } if (process.argv[1] && realpathSync(resolve(process.argv[1])) === fileURLToPath(import.meta.url)) { - try { main(); } catch (error) { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; } + Promise.resolve().then(() => main()).catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); } diff --git a/bin/public-api.mjs b/bin/public-api.mjs new file mode 100644 index 0000000..e17f228 --- /dev/null +++ b/bin/public-api.mjs @@ -0,0 +1,154 @@ +const DEFAULT_API = 'https://api.gapwise.ca/v1'; + +export const help = `Gapwise CLI — campus data and university integration tooling + +Usage: + gapwise universities [--json] + gapwise campuses [--university ID] [--json] + gapwise buildings --university ID [--campus ID] [--query TEXT] [--category academic|residence|facility] [--limit N] [--json] + gapwise residences --university ID [--campus ID] [--limit N] [--json] + gapwise places --university ID [--campus ID] [--query TEXT] [--kind KIND] [--limit N] [--json] + gapwise route --university ID --from CODE --to CODE [--campus ID] [--mode fastest|prefer-indoor|step-free] [--json] + +Maintainer commands (require sibling gapwise and data repositories): + gapwise university create|validate|test|dev ID [--workspace PATH] + gapwise data validate|osm ID [--workspace PATH] + +Options: + --json Print the full public API response for scripting + --help Show this help + --version Show the installed CLI version + +Public commands use the Gapwise API. University and campus IDs are explicit for +campus queries, so a different edition cannot silently use U of T/UTM data. +Docs: https://docs.gapwise.ca/cli/`; + +const publicCommands = new Set(['universities', 'campuses', 'buildings', 'residences', 'places', 'route']); +const allowed = { + universities: new Set(['--json']), + campuses: new Set(['--university', '--json']), + buildings: new Set(['--university', '--campus', '--query', '--category', '--limit', '--json']), + residences: new Set(['--university', '--campus', '--limit', '--json']), + places: new Set(['--university', '--campus', '--query', '--kind', '--limit', '--json']), + route: new Set(['--university', '--campus', '--from', '--to', '--mode', '--json']), +}; + +function parseOptions(command, argv) { + const result = {}; + for (let index = 1; index < argv.length; index++) { + const [key, inline] = argv[index].split(/=(.*)/s, 2); + if (!allowed[command].has(key)) throw new Error(`Unknown option or argument: ${argv[index]}. Run gapwise --help.`); + if (Object.hasOwn(result, key)) throw new Error(`Repeated option: ${key}`); + if (key === '--json') { + if (inline !== undefined) throw new Error('--json takes no value.'); + result[key] = true; + continue; + } + const value = inline ?? argv[++index]; + if (!value || value.startsWith('--')) throw new Error(`${key} needs a value.`); + result[key] = value; + } + return result; +} + +function requireId(id, label) { + if (!id || !/^[a-z][a-z0-9-]*$/.test(id)) throw new Error(`Provide a valid ${label} with --${label} ID.`); +} + +function apiBase() { + const base = process.env.GAPWISE_API_BASE_URL ?? DEFAULT_API; + const url = new URL(base); + if (!['https:', 'http:'].includes(url.protocol)) throw new Error('GAPWISE_API_BASE_URL must be an HTTP URL.'); + return url.href.replace(/\/$/, ''); +} + +async function request(path, options) { + let response; + try { + response = await fetch(`${apiBase()}${path}`, { ...options, signal: AbortSignal.timeout(10000) }); + } catch (error) { + throw new Error(`Gapwise API request failed: ${error.message}. Check your connection and https://status.gapwise.ca.`); + } + let payload; + try { payload = await response.json(); } catch { throw new Error(`Gapwise API returned an invalid response (HTTP ${response.status}).`); } + if (!response.ok) { + const detail = payload.error?.message ?? payload.message ?? response.statusText; + throw new Error(`Gapwise API error (HTTP ${response.status}): ${detail}${payload.meta?.requestId ? ` [request ${payload.meta.requestId}]` : ''}`); + } + return payload; +} + +function print(payload, asJson, render) { + if (asJson) console.log(JSON.stringify(payload, null, 2)); + else render(payload.data); +} + +export async function publicCommand(argv) { + const command = argv[0]; + if (!publicCommands.has(command)) return false; + const opts = parseOptions(command, argv); + const asJson = Boolean(opts['--json']); + if (command === 'universities') { + const payload = await request('/universities'); + print(payload, asJson, (rows) => rows.forEach((row) => console.log(`${row.id}\t${row.name}\t${row.campuses.join(', ')}\t${row.hosts[0]}`))); + return true; + } + let university; + if (opts['--university']) { + requireId(opts['--university'], 'university'); + const directory = await request('/universities'); + university = directory.data.find((entry) => entry.id === opts['--university'] && entry.status === 'supported'); + if (!university) throw new Error(`Unknown or unsupported university: ${opts['--university']}. Run gapwise universities.`); + } else if (command !== 'campuses') { + throw new Error(`--university ID is required for ${command}. Run gapwise universities.`); + } + const campus = opts['--campus']; + if (campus && !university.campuses.includes(campus)) { + throw new Error(`Campus ${campus} does not belong to ${university.name}. Valid campuses: ${university.campuses.join(', ')}.`); + } + if (command === 'campuses') { + const query = university ? `?university=${encodeURIComponent(university.id)}` : ''; + const payload = await request(`/campuses${query}`); + print(payload, asJson, (rows) => rows.forEach((row) => console.log(`${row.id}\t${row.name}\t${row.universityId}\t${row.routable ? 'routing available' : 'routing unavailable'}`))); + return true; + } + if (opts['--limit'] && (!/^[1-9][0-9]*$/.test(opts['--limit']) || Number(opts['--limit']) > 100)) { + throw new Error('--limit must be an integer from 1 to 100.'); + } + if (command === 'route') { + const selectedCampus = campus ?? university.defaultCampus; + if (!university.routableCampuses.includes(selectedCampus)) { + throw new Error(`Routing is unavailable for ${university.name} / ${selectedCampus}. Run gapwise campuses --university ${university.id}.`); + } + if (!opts['--from'] || !opts['--to']) throw new Error('route requires --from CODE and --to CODE.'); + const mode = opts['--mode'] ?? 'fastest'; + if (!['fastest', 'prefer-indoor', 'step-free'].includes(mode)) throw new Error(`Unsupported routing mode: ${mode}.`); + const payload = await request('/routes', { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ from: opts['--from'], to: opts['--to'], university: university.id, campus: selectedCampus, preferences: { mode } }), + }); + print(payload, asJson, (row) => { + console.log(`${university.name} / ${selectedCampus}: ${row.from?.name ?? opts['--from']} → ${row.to?.name ?? opts['--to']}`); + console.log(`${row.status}: ${row.totalDistanceMeters ?? 'unknown'} m, ${row.estimatedSeconds ?? 'unknown'} seconds · ${row.routeVerification ?? 'verification unknown'}`); + for (const warning of row.warnings ?? []) console.log(`Warning: ${typeof warning === 'string' ? warning : JSON.stringify(warning)}`); + }); + return true; + } + const params = new URLSearchParams({ university: university.id, campus: campus ?? university.defaultCampus }); + if (opts['--limit']) params.set('limit', opts['--limit']); + if (opts['--query']) params.set('q', opts['--query']); + if (command === 'residences') params.set('category', 'residence'); + if (opts['--category']) { + if (!['academic', 'residence', 'facility'].includes(opts['--category'])) throw new Error(`Unsupported building category: ${opts['--category']}.`); + params.set('category', opts['--category']); + } + if (opts['--kind']) params.set('kind', opts['--kind']); + const path = command === 'places' ? '/places' : '/buildings'; + const payload = await request(`${path}?${params}`); + print(payload, asJson, (rows) => { + if (!rows.length) console.log(`No ${command} found for ${university.name} / ${campus ?? university.defaultCampus}.`); + else rows.forEach((row) => console.log(`${row.code ?? row.id}\t${row.name}\t${row.category ?? row.kind ?? ''}`)); + if (payload.meta?.pagination?.nextOffset != null) console.log(`More results available; use the public API for pagination: ${DEFAULT_API}${path}`); + }); + return true; +} diff --git a/package.json b/package.json index 3d290c2..f0d830d 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,23 @@ { "name": "@gapwise/cli", - "version": "0.1.0", - "description": "University integration and campus data tooling for Gapwise", + "version": "0.2.0", + "description": "Official Gapwise CLI for multi-university campus discovery and integration tooling", "type": "module", "license": "MIT", - "homepage": "https://docs.gapwise.ca", + "homepage": "https://docs.gapwise.ca/cli/", "repository": { "type": "git", "url": "git+https://github.com/GapwiseHQ/cli.git" }, "bugs": { "url": "https://github.com/GapwiseHQ/cli/issues" }, - "keywords": ["gapwise", "university", "campus", "timetable", "cli"], + "keywords": ["gapwise", "university", "campus", "campus-data", "cli"], "bin": { "gapwise": "./bin/gapwise.mjs" }, - "scripts": { "test": "node --test tests/*.test.mjs" }, - "engines": { "node": ">=24" }, - "files": ["bin", "README.md", "LICENSE"] + "scripts": { + "test": "node --test tests/*.test.mjs", + "test:package": "node scripts/verify-package.mjs", + "check": "npm test && npm run test:package" + }, + "engines": { "node": ">=22" }, + "files": ["bin", "README.md", "LICENSE"], + "publishConfig": { "access": "public" } } diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs new file mode 100644 index 0000000..c0861be --- /dev/null +++ b/scripts/verify-package.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const root = resolve(import.meta.dirname, '..'); +const temp = mkdtempSync(join(tmpdir(), 'gapwise-cli-package-')); +const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); + +function run(command, args, cwd = root) { + const result = spawnSync(command, args, { cwd, encoding: 'utf8', maxBuffer: 1024 * 1024 }); + assert.equal(result.status, 0, `${command} ${args.join(' ')}\n${result.stdout}\n${result.stderr}`); + return result.stdout; +} + +try { + assert.equal(manifest.name, '@gapwise/cli'); + assert.equal(manifest.publishConfig.access, 'public'); + assert.ok(readFileSync(join(root, manifest.bin.gapwise), 'utf8').startsWith('#!/usr/bin/env node\n')); + assert.ok(statSync(join(root, manifest.bin.gapwise)).mode & 0o111, 'CLI entrypoint must be executable'); + + const [packed] = JSON.parse(run('npm', ['pack', '--json', '--pack-destination', temp])); + assert.ok(packed.size < 100_000, `Package exceeds 100 KB: ${packed.size}`); + const paths = packed.files.map(({ path }) => path).sort(); + assert.deepEqual(paths, ['LICENSE', 'README.md', 'bin/gapwise.mjs', 'bin/public-api.mjs', 'package.json']); + const tarListing = run('tar', ['-tvzf', join(temp, packed.filename)]); + assert.match(tarListing, /-rwxr-xr-x\s+[^\n]*package\/bin\/gapwise\.mjs/); + + run('npm', ['install', '--global', '--prefix', temp, '--ignore-scripts', '--no-audit', '--no-fund', join(temp, packed.filename)]); + const binary = join(temp, 'bin/gapwise'); + assert.equal(run(binary, ['--version']).trim(), manifest.version); + assert.match(run(binary, ['--help']), /gapwise universities/); + console.log(`Verified ${manifest.name}@${manifest.version}: ${packed.size} bytes, ${paths.length} files, clean global install and executable.`); +} finally { + rmSync(temp, { recursive: true, force: true }); +} diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index d60ba9d..8ba21a2 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -76,6 +76,16 @@ test('rejects unsafe IDs and duplicate registration', () => { } finally { rmSync(workspace, { recursive: true, force: true }); } }); +test('quoted university names remain valid generated adapter code', () => { + const workspace = fixture(); + try { + const plan = universityPlan('saint-johns', workspace, ['--name', "St. John's University"]); + const adapter = plan.find(([path]) => path.endsWith('/adapter.ts'))[1]; + assert.match(adapter, /throw new Error\("St\. John's University timetable ingestion/); + assert.throws(() => universityPlan('unsafe', workspace, ['--name', 'Line\nBreak']), /single-line/); + } finally { rmSync(workspace, { recursive: true, force: true }); } +}); + test('university validate detects scaffold state and unknown universities', () => { const workspace = fixture(); try { diff --git a/tests/public-api.test.mjs b/tests/public-api.test.mjs new file mode 100644 index 0000000..04e5567 --- /dev/null +++ b/tests/public-api.test.mjs @@ -0,0 +1,110 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { resolve } from 'node:path'; + +const cli = resolve(import.meta.dirname, '../bin/gapwise.mjs'); + +async function run(args, base) { + const child = spawn(process.execPath, [cli, ...args], { + env: { ...process.env, GAPWISE_API_BASE_URL: base }, + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + const [code] = await once(child, 'close'); + return { code, stdout, stderr }; +} + +async function fixture(t) { + const seen = []; + const universities = [ + { id: 'uoft', name: 'University of Toronto', campuses: ['utm', 'utsg', 'utsc'], defaultCampus: 'utm', routableCampuses: ['utm'], hosts: ['gapwise.ca'], status: 'supported' }, + { id: 'carleton', name: 'Carleton University', campuses: ['carleton'], defaultCampus: 'carleton', routableCampuses: ['carleton'], hosts: ['carleton.gapwise.ca'], status: 'supported' }, + { id: 'york', name: 'York University', campuses: ['keele'], defaultCampus: 'keele', routableCampuses: ['keele'], hosts: ['york.gapwise.ca'], status: 'supported' }, + { id: 'tmu', name: 'Toronto Metropolitan University', campuses: ['tmu'], defaultCampus: 'tmu', routableCampuses: ['tmu'], hosts: ['tmu.gapwise.ca'], status: 'supported' }, + ]; + const server = createServer(async (req, res) => { + let body = ''; + for await (const part of req) body += part; + seen.push({ url: req.url, body }); + const url = new URL(req.url, 'http://localhost'); + const data = url.pathname === '/v1/universities' ? universities + : url.pathname === '/v1/campuses' ? [{ id: 'keele', universityId: 'york', name: 'York University', routable: true }] + : url.pathname === '/v1/buildings' ? [{ code: 'BSB', name: 'Behavioural Sciences Building', category: url.searchParams.get('category') ?? 'academic' }] + : url.pathname === '/v1/routes' ? { university: JSON.parse(body).university, campus: JSON.parse(body).campus, distanceMeters: 750 } + : []; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ data, meta: { apiVersion: 'v1' } })); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + t.after(() => server.close()); + return { base: `http://127.0.0.1:${server.address().port}/v1`, seen }; +} + +test('help and version are useful without a checkout or network', async () => { + const help = await run(['--help']); + assert.equal(help.code, 0); + assert.match(help.stdout, /gapwise universities/); + assert.match(help.stdout, /--university ID/); + const version = await run(['--version']); + assert.equal(version.code, 0); + assert.match(version.stdout, /^0\.2\.0\n$/); +}); + +test('discovery and JSON output use the public API', async (t) => { + const { base } = await fixture(t); + const result = await run(['universities', '--json'], base); + assert.equal(result.code, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout).data.map(({ id }) => id), ['uoft', 'carleton', 'york', 'tmu']); + const campuses = await run(['campuses', '--university', 'york'], base); + assert.equal(campuses.code, 0, campuses.stderr); + assert.match(campuses.stdout, /keele\tYork University/); +}); + +test('queries require an explicit university and never inherit UTM', async (t) => { + const { base, seen } = await fixture(t); + const missing = await run(['buildings', '--json'], base); + assert.equal(missing.code, 1); + assert.match(missing.stderr, /--university ID is required/); + assert.equal(seen.length, 0); + const invalid = await run(['buildings', '--university', 'unknown'], base); + assert.equal(invalid.code, 1); + assert.match(invalid.stderr, /Unknown or unsupported university/); + const wrongCampus = await run(['buildings', '--university', 'york', '--campus', 'utm'], base); + assert.equal(wrongCampus.code, 1); + assert.match(wrongCampus.stderr, /does not belong to York University/); + const result = await run(['residences', '--university', 'york', '--json'], base); + assert.equal(result.code, 0, result.stderr); + const request = seen.find(({ url }) => url.startsWith('/v1/buildings')); + assert.match(request.url, /university=york/); + assert.match(request.url, /campus=keele/); + assert.match(request.url, /category=residence/); + assert.doesNotMatch(request.url, /utm/); +}); + +test('route includes the selected university and rejects unsupported campuses', async (t) => { + const { base, seen } = await fixture(t); + const route = await run(['route', '--university', 'carleton', '--from', 'TB', '--to', 'ML', '--json'], base); + assert.equal(route.code, 0, route.stderr); + assert.equal(JSON.parse(route.stdout).data.university, 'carleton'); + const body = JSON.parse(seen.find(({ url }) => url === '/v1/routes').body); + assert.equal(body.campus, 'carleton'); + const unsupported = await run(['route', '--university', 'uoft', '--campus', 'utsg', '--from', 'A', '--to', 'B'], base); + assert.equal(unsupported.code, 1); + assert.match(unsupported.stderr, /Routing is unavailable/); +}); + +test('bad options and network failures return nonzero and actionable errors', async (t) => { + const { base } = await fixture(t); + const bad = await run(['buildings', '--university', 'tmu', '--limit', '999'], base); + assert.equal(bad.code, 1); + assert.match(bad.stderr, /--limit must be/); + const offline = await run(['universities'], 'http://127.0.0.1:1/v1'); + assert.equal(offline.code, 1); + assert.match(offline.stderr, /Gapwise API request failed/); +});