Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
- package-ecosystem: npm
directory: /
schedule:
interval: monthly
9 changes: 6 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
49 changes: 49 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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"
55 changes: 32 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,32 +1,48 @@
<div align="center">

<img src="assets/logo-mark.svg" width="116" alt="Gapwise deer mark" />
<img src="https://raw.githubusercontent.com/GapwiseHQ/cli/main/assets/logo-mark.svg" width="116" alt="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)

</div>

## 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
Expand All @@ -37,25 +53,18 @@ gapwise data validate carleton
gapwise data osm tmu --bbox=-79.39,43.65,-79.37,43.67
```

`university create <id> --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=<id>` 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.
35 changes: 35 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 20 additions & 2 deletions bin/gapwise.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, '../..');
Expand Down Expand Up @@ -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.');
Expand All @@ -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<ParsedTimetable> {\n throw new Error('${name} timetable ingestion has not been implemented.');\n}\n\nexport async function load${pascalName}DemoTimetable(): Promise<Meeting[]> {\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<ParsedTimetable> {\n throw new Error(${JSON.stringify(`${name} timetable ingestion has not been implemented.`)});\n}\n\nexport async function load${pascalName}DemoTimetable(): Promise<Meeting[]> {\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`;
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand All @@ -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;
});
}
Loading
Loading