ninepm 0.51.0 → 0.52.0

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.
package/dist/index.d.ts CHANGED
@@ -114,7 +114,7 @@ export interface CreatedServiceToken {
114
114
  clientSecret: string;
115
115
  }
116
116
  export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
117
- export declare const CLI_VERSION = "0.51.0";
117
+ export declare const CLI_VERSION = "0.52.0";
118
118
  type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
119
119
  export interface StoredCredentialEntry {
120
120
  token: string;
@@ -291,6 +291,17 @@ export interface UpdateNudgeOptions {
291
291
  env?: NodeJS.ProcessEnv;
292
292
  }
293
293
  export declare function deployUpdateNudge(options?: UpdateNudgeOptions): Promise<string | null>;
294
+ export interface SkillFreshness {
295
+ status: "up_to_date" | "stale" | "not_installed" | "unknown";
296
+ installedStamp?: string;
297
+ publishedStamp?: string;
298
+ detail?: string;
299
+ }
300
+ export declare function checkSkillFreshness(options?: {
301
+ fetchFn?: FetchLike;
302
+ skillPath?: string;
303
+ timeoutMs?: number;
304
+ }): Promise<SkillFreshness>;
294
305
  export interface ListSitesResult {
295
306
  projects: Project[];
296
307
  }
@@ -360,6 +371,8 @@ export interface DeviceAuthProgressReporter {
360
371
  stop(): void;
361
372
  }
362
373
  export declare function createDeviceAuthProgressReporter(stream?: NodeJS.WriteStream): DeviceAuthProgressReporter;
374
+ export declare function noteCliOutdatedAdvisory(response: Response, log?: (line: string) => void): void;
375
+ export declare function resetCliOutdatedAdvisoryForTests(): void;
363
376
  type CliCommand = {
364
377
  kind: "deploy";
365
378
  options: DeployOptions;
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import { MAX_DEPLOY_DESCRIPTION_LENGTH, normalizeDeployDescription } from "./dep
14
14
  import { MAX_STATIC_ASSET_FILE_BYTES, MAX_WORKER_ENTRY_COMPRESSED_BYTES, MAX_WORKER_SCRIPT_COMPRESSED_BYTES, WORKER_ENTRY_COMPRESSED_WARN_BYTES, assetFileTooLargeMessage, formatMebibytes, formatMegabytes, oversizedAssets, workerBundleNearLimitMessage, workerBundleTooLargeMessage, workerEntryNearLimitMessage, workerEntryTooLargeMessage, } from "./deploy-limits.js";
15
15
  import { buildFileManifest, readManifestFile } from "./manifest.js";
16
16
  import { API_KEY_HELP, API_URL_HELP, CLI_CLIENT_ID, DEFAULT_API_URL, reportCliError } from "./cli-config.js";
17
- export const CLI_VERSION = "0.51.0";
17
+ export const CLI_VERSION = "0.52.0";
18
18
  const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
19
19
  const TOKEN_SERVICE = "9pm";
20
20
  const TOKEN_ACCOUNT = "default";
@@ -499,6 +499,7 @@ async function runDeploy(initialOptions, slugHolder) {
499
499
  method: upload.method,
500
500
  headers: {
501
501
  authorization: `Bearer ${options.token}`,
502
+ ...cliVersionHeader(),
502
503
  ...upload.headers,
503
504
  },
504
505
  body: arrayBufferFromBytes(body),
@@ -856,8 +857,9 @@ async function doctor(options) {
856
857
  catch (error) {
857
858
  versionError = error instanceof Error ? error.message : String(error);
858
859
  }
860
+ const skill = await checkSkillFreshness();
859
861
  if (options.json) {
860
- console.log(JSON.stringify({ apiUrl: normalizeApiUrl(options.apiUrl), credentials, account, accountError, version: versionCheck, versionError }, null, 2));
862
+ console.log(JSON.stringify({ apiUrl: normalizeApiUrl(options.apiUrl), credentials, account, accountError, version: versionCheck, versionError, skill }, null, 2));
861
863
  return;
862
864
  }
863
865
  console.log("9pm.ai doctor");
@@ -898,6 +900,18 @@ async function doctor(options) {
898
900
  else if (versionError) {
899
901
  console.log(`CLI: ${CLI_VERSION} (latest unavailable: ${versionError})`);
900
902
  }
903
+ if (skill.status === "up_to_date") {
904
+ console.log(`Skill: up to date (stamp ${skill.installedStamp})`);
905
+ }
906
+ else if (skill.status === "stale") {
907
+ console.log(`Skill: stale (installed ${skill.installedStamp ?? "no stamp"}, published ${skill.publishedStamp}). Refresh: ${SKILL_REFRESH_COMMAND}`);
908
+ }
909
+ else if (skill.status === "not_installed") {
910
+ console.log("Skill: not installed (expected at ~/.agents/skills/9pm/SKILL.md)");
911
+ }
912
+ else {
913
+ console.log(`Skill: installed; freshness unknown (${skill.detail ?? "no detail"})`);
914
+ }
901
915
  }
902
916
  export async function getSiteAccess(options) {
903
917
  return apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}/access`, {
@@ -1071,6 +1085,56 @@ export async function deployUpdateNudge(options = {}) {
1071
1085
  }
1072
1086
  return `Update available: 9pm CLI ${CLI_VERSION} -> ${latest}. Run: npm i -g ninepm@latest`;
1073
1087
  }
1088
+ // Skill-freshness half of `9pm doctor` (#996): compare the installed skill's
1089
+ // stamp line against the public source copy — the same reference SKILL.md's
1090
+ // own "Keeping this skill fresh" section names, and the source the install
1091
+ // command actually pulls from (the platform-hosted copy tracks releases and
1092
+ // can lag it). Never throws: doctor is diagnostics, not a gate.
1093
+ const SKILL_INSTALL_PATH_SEGMENTS = [".agents", "skills", "9pm", "SKILL.md"];
1094
+ const SKILL_SOURCE_URL = "https://raw.githubusercontent.com/tmrw-realityos/9pm-skill/main/skills/9pm/SKILL.md";
1095
+ const SKILL_STAMP_PATTERN = /^Skill guide stamp:\s*(\S+)/m;
1096
+ const SKILL_REFRESH_COMMAND = "npx skills add tmrw-realityos/9pm-skill --skill 9pm -g";
1097
+ export async function checkSkillFreshness(options = {}) {
1098
+ const skillPath = options.skillPath ?? path.join(os.homedir(), ...SKILL_INSTALL_PATH_SEGMENTS);
1099
+ let installed;
1100
+ try {
1101
+ installed = readFileSync(skillPath, "utf8");
1102
+ }
1103
+ catch {
1104
+ return { status: "not_installed" };
1105
+ }
1106
+ const installedStamp = SKILL_STAMP_PATTERN.exec(installed)?.[1] ?? null;
1107
+ const fetchFn = options.fetchFn ?? fetch;
1108
+ const controller = new AbortController();
1109
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 3000);
1110
+ let publishedStamp = null;
1111
+ try {
1112
+ const response = await fetchFn(SKILL_SOURCE_URL, { signal: controller.signal });
1113
+ if (!response.ok) {
1114
+ throw new Error(`skill source returned ${response.status}`);
1115
+ }
1116
+ publishedStamp = SKILL_STAMP_PATTERN.exec(await response.text())?.[1] ?? null;
1117
+ }
1118
+ catch (error) {
1119
+ return {
1120
+ status: "unknown",
1121
+ ...(installedStamp ? { installedStamp } : {}),
1122
+ detail: error instanceof Error ? error.message : String(error),
1123
+ };
1124
+ }
1125
+ finally {
1126
+ clearTimeout(timer);
1127
+ }
1128
+ if (!publishedStamp) {
1129
+ return { status: "unknown", ...(installedStamp ? { installedStamp } : {}), detail: "published copy has no stamp line" };
1130
+ }
1131
+ // An installed copy with no stamp predates the stamp convention, which is
1132
+ // itself proof it is behind.
1133
+ if (!installedStamp || installedStamp !== publishedStamp) {
1134
+ return { status: "stale", ...(installedStamp ? { installedStamp } : {}), publishedStamp };
1135
+ }
1136
+ return { status: "up_to_date", installedStamp, publishedStamp };
1137
+ }
1074
1138
  // Best-effort read of the update-check cache; the caller judges freshness.
1075
1139
  // checkedAtMs is null when the file is missing or malformed (so the next probe
1076
1140
  // runs now), and latest is null when the last attempt failed to get an answer
@@ -1114,7 +1178,7 @@ async function deviceAuthFlow(options) {
1114
1178
  // made here; an unpredictable store failure is caught after the poll instead.
1115
1179
  const startResponse = await fetchFn(`${apiUrl}/v1/oauth/device/start`, {
1116
1180
  method: "POST",
1117
- headers: { "content-type": "application/json" },
1181
+ headers: { "content-type": "application/json", ...cliVersionHeader() },
1118
1182
  body: JSON.stringify({ machineName: options.machineName ?? os.hostname(), purpose: options.purpose }),
1119
1183
  });
1120
1184
  // The control plane refuses a signup it knows can never be approved (#507) with
@@ -1180,7 +1244,7 @@ async function deviceAuthFlow(options) {
1180
1244
  await sleep(intervalMs);
1181
1245
  const pollResponse = await fetchFn(`${apiUrl}/v1/oauth/device/poll`, {
1182
1246
  method: "POST",
1183
- headers: { "content-type": "application/json" },
1247
+ headers: { "content-type": "application/json", ...cliVersionHeader() },
1184
1248
  body: JSON.stringify({ code: deviceCode }),
1185
1249
  });
1186
1250
  if (pollResponse.status === 400) {
@@ -1349,6 +1413,7 @@ export async function putManagedFile(options) {
1349
1413
  headers: {
1350
1414
  authorization: `Bearer ${options.token}`,
1351
1415
  "content-type": "application/octet-stream",
1416
+ ...cliVersionHeader(),
1352
1417
  },
1353
1418
  body: bytes,
1354
1419
  });
@@ -1359,7 +1424,7 @@ export async function getManagedFile(options) {
1359
1424
  const apiUrl = normalizeApiUrl(options.apiUrl);
1360
1425
  const response = await fetchFn(`${apiUrl}${filesRoute(options)}/${encodeURIComponent(options.fileId)}`, {
1361
1426
  method: "GET",
1362
- headers: { authorization: `Bearer ${options.token}` },
1427
+ headers: { authorization: `Bearer ${options.token}`, ...cliVersionHeader() },
1363
1428
  });
1364
1429
  await expectOk(response, "download file");
1365
1430
  const bytes = new Uint8Array(await response.arrayBuffer());
@@ -1432,7 +1497,7 @@ async function finalizeDeployment(fetchFn, apiUrl, token, deploymentId) {
1432
1497
  async function getDeployment(fetchFn, apiUrl, token, deploymentId) {
1433
1498
  const response = await fetchFn(`${apiUrl}/v1/deployments/${encodeURIComponent(deploymentId)}`, {
1434
1499
  method: "GET",
1435
- headers: { authorization: `Bearer ${token}` },
1500
+ headers: { authorization: `Bearer ${token}`, ...cliVersionHeader() },
1436
1501
  });
1437
1502
  const payload = await expectJson(response, "get deployment");
1438
1503
  return payload.deployment;
@@ -1869,20 +1934,56 @@ function jsonHeaders(token) {
1869
1934
  return {
1870
1935
  authorization: `Bearer ${token}`,
1871
1936
  "content-type": "application/json",
1872
- "x-9pm-cli-version": CLI_VERSION,
1937
+ ...cliVersionHeader(),
1873
1938
  };
1874
1939
  }
1940
+ // Every control-plane request must carry the CLI's version (#996): the server's
1941
+ // version floor only judges requests that have it, so a hand-built header object
1942
+ // that omits it silently exempts that call from both the advisory and an
1943
+ // enforced floor. Spread this into any headers literal that bypasses
1944
+ // apiRequest/jsonHeaders.
1945
+ function cliVersionHeader() {
1946
+ return { "x-9pm-cli-version": CLI_VERSION };
1947
+ }
1875
1948
  async function expectJson(response, action) {
1876
1949
  await expectOk(response, action);
1877
1950
  return (await response.json());
1878
1951
  }
1879
1952
  async function expectOk(response, action) {
1953
+ noteCliOutdatedAdvisory(response);
1880
1954
  if (response.ok) {
1881
1955
  return;
1882
1956
  }
1957
+ // 426 is the control plane's enforced CLI floor (#996): the server-written
1958
+ // message IS the response — surface it alone rather than burying it in the
1959
+ // raw envelope the generic path prints.
1960
+ if (response.status === 426) {
1961
+ throw new Error(await apiErrorMessage(response, action));
1962
+ }
1883
1963
  const text = await response.text();
1884
1964
  throw new Error(`Failed to ${action}: ${response.status} ${text}`);
1885
1965
  }
1966
+ // Advisory half of the control plane's CLI floor (#996): when a response says
1967
+ // this version is below the platform's recommended minimum, say so once per
1968
+ // invocation, on stderr so command output (including --json) stays parseable.
1969
+ const CLI_OUTDATED_HEADER = "x-9pm-cli-outdated";
1970
+ let cliOutdatedAdvisoryPrinted = false;
1971
+ export function noteCliOutdatedAdvisory(response, log = (line) => console.error(line)) {
1972
+ if (cliOutdatedAdvisoryPrinted) {
1973
+ return;
1974
+ }
1975
+ const min = response.headers.get(CLI_OUTDATED_HEADER);
1976
+ if (!min) {
1977
+ return;
1978
+ }
1979
+ cliOutdatedAdvisoryPrinted = true;
1980
+ log(`Note: this CLI (${CLI_VERSION}) is older than the platform's recommended minimum (${min}). Update: npm i -g ninepm@latest`);
1981
+ }
1982
+ // Test seam: the once-per-invocation latch above is module state, which one
1983
+ // test process shares across cases.
1984
+ export function resetCliOutdatedAdvisoryForTests() {
1985
+ cliOutdatedAdvisoryPrinted = false;
1986
+ }
1886
1987
  // Pull the control plane's own message out of its {error:{code,message}} envelope,
1887
1988
  // for the few refusals whose message is written for the tester and is the whole
1888
1989
  // point of the response (e.g. device signup being unavailable, #507). Dumping the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ninepm",
3
- "version": "0.51.0",
3
+ "version": "0.52.0",
4
4
  "description": "CLI for deploying built apps to 9pm.ai.",
5
5
  "homepage": "https://9pm.ai",
6
6
  "keywords": [