@airig/cli 0.0.1 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +27 -13
  2. package/dist/index.js +188 -3
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -7,7 +7,7 @@ Distribute and manage AI setups across coding agents from one project-local `.ai
7
7
  Install the CLI globally to use the short `airig` command:
8
8
 
9
9
  ```sh
10
- npm install --global airig
10
+ npm install --global @airig/cli
11
11
  ```
12
12
 
13
13
  ```sh
@@ -18,34 +18,48 @@ airig remove [owner/repo|.]
18
18
  airig publish [tag]
19
19
  ```
20
20
 
21
- For one-off usage without a global install, run the npm package directly:
21
+ For one-off usage without a global install, run the npm Package directly:
22
22
 
23
23
  ```sh
24
- npx airig add <owner/repo>[@version]
25
- npx airig add .
26
- npx airig update <owner/repo>@<version>
27
- npx airig remove [owner/repo|.]
28
- npx airig publish [tag]
24
+ npx @airig/cli add <owner/repo>[@version]
25
+ npx @airig/cli add .
26
+ npx @airig/cli update <owner/repo>@<version>
27
+ npx @airig/cli remove [owner/repo|.]
28
+ npx @airig/cli publish [tag]
29
29
  ```
30
30
 
31
- The package is named `airig`; the installed binary is `airig`.
31
+ The Package is named `@airig/cli`; the installed binary is `airig`.
32
32
 
33
33
  ## What It Does
34
34
 
35
- airig installs selected AI setup artifacts from immutable GitHub releases into `.ai/`, then links them into provider-specific config paths. It supports local author dogfooding with `add .`, explicit version updates, interactive removal, and publishing `.ai/` as an `ai.zip` release asset.
35
+ airig installs selected AI Setup artifacts from immutable GitHub releases into `.ai/`, then links them into provider-specific config paths. It supports local author dogfooding with `add .`, explicit version updates, interactive removal, and publishing `.ai/` as an `ai.zip` release asset.
36
36
 
37
- Remote setup releases are pinned to exact versions in `.ai/ai.json`. `add` and `update` verify GitHub release immutability before writing remote content.
37
+ Remote Setup Releases are pinned to exact versions in `.ai/ai.json`. `add` and `update` verify GitHub release immutability before writing remote content.
38
+
39
+ ## Maintainer Releases
40
+
41
+ Use Package releases to publish the `@airig/cli` npm Package, which provides the `airig` CLI:
42
+
43
+ ```sh
44
+ pnpm release
45
+ ```
46
+
47
+ The release script is maintainer-facing. It uses `bumpp` to choose the next Package version, update Package metadata, create the release commit, create a `v<version>` tag, and push the commit and tag. Pushed `v*` tags trigger `.github/workflows/publish-package.yml`, which installs dependencies, runs tests, builds the CLI, and publishes the Package to npm.
48
+
49
+ npm publishing uses trusted publishing with GitHub Actions OIDC. Do not add a long-lived npm token for Package releases.
50
+
51
+ Package releases are separate from Setup Releases. `airig publish [tag]` creates a GitHub immutable Setup Release containing `ai.zip` from `.ai/`; it does not publish the npm Package.
38
52
 
39
53
  ## Author Workflow
40
54
 
41
- 1. Create setup artifacts under `.ai/`.
55
+ 1. Create AI Setup artifacts under `.ai/`.
42
56
  2. Run `airig add .` to wire local artifacts into your repo.
43
57
  3. Tag a release with your normal git tooling.
44
- 4. Run `airig publish` to upload `ai.zip` to an immutable GitHub release.
58
+ 4. Run `airig publish` to upload `ai.zip` to an immutable GitHub Setup Release.
45
59
  5. Share `airig add yourname/repo`.
46
60
 
47
61
  ## Requirements
48
62
 
49
63
  - Node.js `24.11.0` or newer in the Node 24 release line.
50
- - GitHub immutable releases enabled for repositories that publish setup releases.
64
+ - GitHub immutable releases enabled for repositories that publish Setup Releases.
51
65
  - `GITHUB_TOKEN` with repository write access when running `publish`.
package/dist/index.js CHANGED
@@ -1,15 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
- import { execSync } from "node:child_process";
3
+ import { execSync, spawn } from "node:child_process";
4
4
  import { cp, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rm, symlink, unlink, writeFile } from "node:fs/promises";
5
5
  import path, { join } from "node:path";
6
6
  import { parseEnv } from "node:util";
7
7
  import archiver from "archiver";
8
- import { createWriteStream, existsSync } from "node:fs";
8
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { Octokit } from "@octokit/rest";
10
10
  import { checkbox } from "@inquirer/prompts";
11
11
  import os from "node:os";
12
12
  import extractZip from "extract-zip";
13
+ import { fileURLToPath } from "node:url";
13
14
  //#region src/lib/zip.ts
14
15
  function create(sourceDir, outputPath) {
15
16
  return new Promise((resolve, reject) => {
@@ -871,12 +872,196 @@ const updateCommand = new Command("update").description("Refresh an installed Se
871
872
  }
872
873
  });
873
874
  //#endregion
875
+ //#region src/lib/update-notifier.ts
876
+ const PACKAGE_NAME = "@airig/cli";
877
+ const UPDATE_CHECK_INTERVAL_MS = 1440 * 60 * 1e3;
878
+ const UPDATE_NOTIFICATION_INTERVAL_MS = 1440 * 60 * 1e3;
879
+ const REGISTRY_LATEST_URL = "https://registry.npmjs.org/@airig%2fcli/latest";
880
+ function maybeNotifyForUpdate(options = {}) {
881
+ const env = options.env ?? process.env;
882
+ const stderr = options.stderr ?? process.stderr;
883
+ if (shouldSkipUpdateNotifier(env, stderr)) return;
884
+ const packageJsonPath = options.packageJsonPath ?? findOwnPackageJsonPath();
885
+ if (!isGlobalInstall(path.dirname(packageJsonPath), options.cwd ?? process.cwd())) return;
886
+ const now = options.now ?? /* @__PURE__ */ new Date();
887
+ const currentVersion = readPackageVersion(packageJsonPath);
888
+ const statePath = path.join(options.stateDir ?? defaultStateDir(env), "update-notifier.json");
889
+ let state = readUpdateState(statePath);
890
+ if (isUpdateCheckDue(state, now)) {
891
+ state = {
892
+ ...state,
893
+ lastUpdateCheck: now.toUTCString()
894
+ };
895
+ writeUpdateState(statePath, state);
896
+ (options.spawnUpdateCheck ?? spawnUpdateCheck)(statePath);
897
+ }
898
+ if (!state.latestVersion || !isVersionGreater(state.latestVersion, currentVersion)) return;
899
+ if (!isNotificationDue(state, now)) return;
900
+ stderr.write(formatUpdateMessage({
901
+ currentVersion,
902
+ latestVersion: state.latestVersion
903
+ }));
904
+ writeUpdateState(statePath, {
905
+ ...state,
906
+ lastNotifiedAt: now.toUTCString()
907
+ });
908
+ }
909
+ function shouldSkipUpdateNotifier(env, stderr) {
910
+ return Boolean(env.NO_UPDATE_NOTIFIER || env.AIRIG_NO_UPDATE_NOTIFIER || env.CI || env.NODE_ENV === "test" || !stderr.isTTY);
911
+ }
912
+ function readPackageVersion(packageJsonPath) {
913
+ const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8"));
914
+ if (!parsed.version) throw new Error(`Could not read ${PACKAGE_NAME} version from ${packageJsonPath}`);
915
+ return parsed.version;
916
+ }
917
+ function defaultStateDir(env) {
918
+ if (env.AIRIG_STATE_DIR) return env.AIRIG_STATE_DIR;
919
+ if (env.XDG_STATE_HOME) return path.join(env.XDG_STATE_HOME, "airig");
920
+ return path.join(os.homedir(), ".local", "state", "airig");
921
+ }
922
+ function readUpdateState(statePath) {
923
+ try {
924
+ return JSON.parse(readFileSync(statePath, "utf8"));
925
+ } catch {
926
+ return {};
927
+ }
928
+ }
929
+ function writeUpdateState(statePath, state) {
930
+ mkdirSync(path.dirname(statePath), { recursive: true });
931
+ writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`);
932
+ }
933
+ function isUpdateCheckDue(state, now) {
934
+ return isIntervalDue(state.lastUpdateCheck, now, UPDATE_CHECK_INTERVAL_MS);
935
+ }
936
+ function isNotificationDue(state, now) {
937
+ return isIntervalDue(state.lastNotifiedAt, now, UPDATE_NOTIFICATION_INTERVAL_MS);
938
+ }
939
+ function isIntervalDue(previous, now, intervalMs) {
940
+ if (!previous) return true;
941
+ const previousTime = new Date(previous).valueOf();
942
+ if (Number.isNaN(previousTime)) return true;
943
+ return now.valueOf() - previousTime >= intervalMs;
944
+ }
945
+ function spawnUpdateCheck(statePath) {
946
+ spawn(process.execPath, [
947
+ "--input-type=module",
948
+ "--eval",
949
+ UPDATE_CHECK_SCRIPT
950
+ ], {
951
+ detached: true,
952
+ stdio: "ignore",
953
+ env: {
954
+ ...process.env,
955
+ AIRIG_UPDATE_STATE_PATH: statePath,
956
+ AIRIG_UPDATE_REGISTRY_URL: REGISTRY_LATEST_URL
957
+ }
958
+ }).unref();
959
+ }
960
+ const UPDATE_CHECK_SCRIPT = `
961
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
962
+ import path from 'node:path'
963
+
964
+ const statePath = process.env.AIRIG_UPDATE_STATE_PATH
965
+ const registryUrl = process.env.AIRIG_UPDATE_REGISTRY_URL
966
+ const now = new Date().toUTCString()
967
+
968
+ async function readState() {
969
+ try {
970
+ return JSON.parse(await readFile(statePath, 'utf8'))
971
+ } catch {
972
+ return {}
973
+ }
974
+ }
975
+
976
+ async function writeState(state) {
977
+ await mkdir(path.dirname(statePath), { recursive: true })
978
+ await writeFile(statePath, JSON.stringify(state, null, 2) + '\\n')
979
+ }
980
+
981
+ const state = await readState()
982
+
983
+ try {
984
+ const response = await fetch(registryUrl, { signal: AbortSignal.timeout(3000) })
985
+ const data = response.ok ? await response.json() : undefined
986
+ await writeState({ ...state, lastUpdateCheck: now, latestVersion: data?.version ?? state.latestVersion })
987
+ } catch {
988
+ await writeState({ ...state, lastUpdateCheck: now })
989
+ }
990
+ `;
991
+ function isVersionGreater(candidate, current) {
992
+ const candidateVersion = parseSemver(candidate);
993
+ const currentVersion = parseSemver(current);
994
+ if (!candidateVersion || !currentVersion) return candidate !== current;
995
+ for (let i = 0; i < 3; i += 1) {
996
+ const diff = candidateVersion.numbers[i] - currentVersion.numbers[i];
997
+ if (diff !== 0) return diff > 0;
998
+ }
999
+ if (candidateVersion.prerelease === currentVersion.prerelease) return false;
1000
+ if (!candidateVersion.prerelease) return Boolean(currentVersion.prerelease);
1001
+ if (!currentVersion.prerelease) return false;
1002
+ return candidateVersion.prerelease.localeCompare(currentVersion.prerelease, void 0, { numeric: true }) > 0;
1003
+ }
1004
+ function parseSemver(version) {
1005
+ const match = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
1006
+ if (!match) return void 0;
1007
+ return {
1008
+ numbers: [
1009
+ Number(match[1]),
1010
+ Number(match[2]),
1011
+ Number(match[3])
1012
+ ],
1013
+ prerelease: match[4] ?? ""
1014
+ };
1015
+ }
1016
+ function isGlobalInstall(packageRoot, cwd) {
1017
+ if (isOneOffInstallPath(packageRoot)) return false;
1018
+ if (findProjectRootForLocalInstall(packageRoot, cwd)) return false;
1019
+ return true;
1020
+ }
1021
+ function findOwnPackageJsonPath() {
1022
+ let currentDir = path.dirname(fileURLToPath(import.meta.url));
1023
+ while (true) {
1024
+ const candidate = path.join(currentDir, "package.json");
1025
+ try {
1026
+ if (JSON.parse(readFileSync(candidate, "utf8")).name === PACKAGE_NAME) return candidate;
1027
+ } catch {}
1028
+ const parentDir = path.dirname(currentDir);
1029
+ if (parentDir === currentDir) break;
1030
+ currentDir = parentDir;
1031
+ }
1032
+ throw new Error(`Could not locate ${PACKAGE_NAME} package metadata`);
1033
+ }
1034
+ function isOneOffInstallPath(packageRoot) {
1035
+ const normalizedRoot = packageRoot.split(path.sep).join("/");
1036
+ return normalizedRoot.includes("/_npx/") || normalizedRoot.includes("/.npm/_npx/");
1037
+ }
1038
+ function findProjectRootForLocalInstall(packageRoot, cwd) {
1039
+ const segments = packageRoot.split(path.sep);
1040
+ for (let index = segments.length - 1; index > 0; index -= 1) {
1041
+ if (segments[index] !== "node_modules") continue;
1042
+ const projectRoot = segments.slice(0, index).join(path.sep) || path.sep;
1043
+ if (existsSync(path.join(projectRoot, "package.json")) && isSameOrInside(cwd, projectRoot)) return projectRoot;
1044
+ }
1045
+ }
1046
+ function isSameOrInside(candidate, parent) {
1047
+ const relative = path.relative(parent, candidate);
1048
+ return relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative);
1049
+ }
1050
+ function formatUpdateMessage(opts) {
1051
+ return `\nA new airig version is available: ${opts.currentVersion} -> ${opts.latestVersion}.\nYour installed airig package is outdated. Please upgrade to the latest version.\n\n`;
1052
+ }
1053
+ //#endregion
874
1054
  //#region src/index.ts
875
1055
  const program = new Command("airig").description("Manage project-scoped AI Setup artifacts");
876
1056
  program.addCommand(publishCommand);
877
1057
  program.addCommand(addCommand);
878
1058
  program.addCommand(updateCommand);
879
1059
  program.addCommand(removeCommand);
880
- program.parse();
1060
+ program.hook("postAction", () => {
1061
+ try {
1062
+ maybeNotifyForUpdate();
1063
+ } catch {}
1064
+ });
1065
+ await program.parseAsync();
881
1066
  //#endregion
882
1067
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@airig/cli",
3
- "version": "0.0.1",
3
+ "version": "0.0.4",
4
4
  "description": "Distribute and manage AI setups across providers",
5
5
  "license": "MIT",
6
6
  "type": "module",