ninepm 0.56.0 → 0.58.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/README.md CHANGED
@@ -76,6 +76,7 @@ Attach an optional short description to the deploy (shown next to the version in
76
76
 
77
77
  ```sh
78
78
  9pm deploy ./dist --name my-app --slug my-app --description "Fix login redirect"
79
+ 9pm deploy ./dist --name my-app --slug my-app --owner acme # into an organisation you are an admin of
79
80
  ```
80
81
 
81
82
  The CLI prints the public app URL after upload and finalization.
package/dist/index.d.ts CHANGED
@@ -7,6 +7,7 @@ export interface DeployOptions {
7
7
  token: string;
8
8
  name?: string;
9
9
  slug?: string;
10
+ owner?: string;
10
11
  description?: string;
11
12
  environment?: string;
12
13
  withDatabase?: boolean;
@@ -50,6 +51,7 @@ export interface SiteAccessOptions extends ApiOptions {
50
51
  }
51
52
  export interface DeleteSiteOptions extends SiteAccessOptions {
52
53
  confirmSlug: string;
54
+ owner?: string;
53
55
  keepRecord?: boolean;
54
56
  }
55
57
  export interface AccessState {
@@ -114,7 +116,7 @@ export interface CreatedServiceToken {
114
116
  clientSecret: string;
115
117
  }
116
118
  export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
117
- export declare const CLI_VERSION = "0.56.0";
119
+ export declare const CLI_VERSION = "0.58.0";
118
120
  type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
119
121
  export interface StoredCredentialEntry {
120
122
  token: string;
@@ -218,7 +220,7 @@ export declare function measureWorkerEntry(source: Uint8Array): {
218
220
  rawBytes: number;
219
221
  compressedBytes: number;
220
222
  };
221
- export declare function measureWorkerModuleFiles(root: string, modulePaths: readonly string[]): Promise<{
223
+ export declare function measureWorkerModuleFiles(root: string, modulePaths: readonly string[], onSource?: (source: string) => void): Promise<{
222
224
  rawBytes: number;
223
225
  compressedBytes: number;
224
226
  }>;
@@ -321,7 +323,9 @@ export declare function checkSkillFreshness(options?: {
321
323
  export interface ListSitesResult {
322
324
  projects: Project[];
323
325
  }
324
- export declare function listSites(options: ApiOptions): Promise<ListSitesResult>;
326
+ export declare function listSites(options: ApiOptions & {
327
+ owner?: string;
328
+ }): Promise<ListSitesResult>;
325
329
  export declare function deleteSite(options: DeleteSiteOptions): Promise<DeletedSiteResult>;
326
330
  export interface DeviceAuthFeedbackOptions {
327
331
  progressStream?: NodeJS.WriteStream;
@@ -430,6 +434,7 @@ type CliCommand = {
430
434
  apiUrl: string;
431
435
  token: string;
432
436
  json: boolean;
437
+ owner?: string;
433
438
  } | {
434
439
  kind: "logs";
435
440
  site: string;
package/dist/index.js CHANGED
@@ -11,10 +11,11 @@ import { once } from "node:events";
11
11
  import { createGzip, gzipSync } from "node:zlib";
12
12
  import { Agent, fetch as undiciFetch } from "undici";
13
13
  import { MAX_DEPLOY_DESCRIPTION_LENGTH, normalizeDeployDescription } from "./deploy-description.js";
14
+ import { managedHostnamesIn, siblingCallWarning, siblingsOf } from "./sibling-hostnames.js";
14
15
  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
16
  import { buildFileManifest, readManifestFile } from "./manifest.js";
16
17
  import { API_KEY_HELP, API_URL_HELP, CLI_CLIENT_ID, DEFAULT_API_URL, reportCliError } from "./cli-config.js";
17
- export const CLI_VERSION = "0.56.0";
18
+ export const CLI_VERSION = "0.58.0";
18
19
  const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
19
20
  const TOKEN_SERVICE = "9pm";
20
21
  const TOKEN_ACCOUNT = "default";
@@ -269,6 +270,7 @@ export async function deployDirectory(initialOptions) {
269
270
  ...(initialOptions.fetchFn ? { fetchFn: initialOptions.fetchFn } : {}),
270
271
  site: slugHolder.slug,
271
272
  confirmSlug: slugHolder.slug,
273
+ ...(initialOptions.owner ? { owner: initialOptions.owner } : {}),
272
274
  ...(slugHolder.retain ? { keepRecord: true } : {}),
273
275
  });
274
276
  }
@@ -339,8 +341,8 @@ async function runDeploy(initialOptions, slugHolder) {
339
341
  }
340
342
  const image = buildContainerImage(options.directory);
341
343
  try {
342
- const projectInput = { name: options.name ?? path.basename(path.resolve(options.directory)) };
343
- const { project, created: projectCreated } = await createProject(fetchFn, apiUrl, options.token, options.slug ? { ...projectInput, slug: options.slug } : projectInput);
344
+ const projectInput = { name: options.name ?? path.basename(path.resolve(options.directory)), ...(options.slug ? { slug: options.slug } : {}), ...(options.owner ? { owner: options.owner } : {}) };
345
+ const { project, created: projectCreated } = await createProject(fetchFn, apiUrl, options.token, projectInput);
344
346
  slugHolder.slug = project.slug;
345
347
  slugHolder.created = projectCreated;
346
348
  slugHolder.neverActive = project.activeDeploymentId === null;
@@ -490,13 +492,22 @@ async function runDeploy(initialOptions, slugHolder) {
490
492
  console.warn(`Warning: ${workerEntryNearLimitMessage(serverEntry, rawBytes, compressedBytes)}`);
491
493
  }
492
494
  }
495
+ // Managed app hostnames written into this app's server code (#1332), filled from the
496
+ // size passes above and below - the only passes that hold every server module's text -
497
+ // and read once the project id exists. A bundle is streamed through gzip precisely so it
498
+ // is never all in memory at once, so the matches are what gets carried, not the sources.
499
+ const managedHostnames = new Set();
493
500
  // Bundles carry the same budget, applied to all their server modules together (#660).
494
501
  // Without this the `--bundle` path — the one the hosted skill's size guidance is about —
495
502
  // was the only worker shape with no local size check at all, so an over-budget framework
496
503
  // build failed at publish instead, after the project and slug existed.
497
504
  if (bundle) {
498
505
  const modulePaths = bundleModulePaths(bundle);
499
- const { rawBytes, compressedBytes } = await measureWorkerModuleFiles(contentRoot, modulePaths);
506
+ const { rawBytes, compressedBytes } = await measureWorkerModuleFiles(contentRoot, modulePaths, (source) => {
507
+ for (const hostname of managedHostnamesIn(source)) {
508
+ managedHostnames.add(hostname);
509
+ }
510
+ });
500
511
  if (compressedBytes > MAX_WORKER_ENTRY_COMPRESSED_BYTES) {
501
512
  throw new Error(workerBundleTooLargeMessage(modulePaths.length, rawBytes, compressedBytes));
502
513
  }
@@ -504,10 +515,30 @@ async function runDeploy(initialOptions, slugHolder) {
504
515
  console.warn(`Warning: ${workerBundleNearLimitMessage(modulePaths.length, rawBytes, compressedBytes)}`);
505
516
  }
506
517
  }
507
- const projectInput = { name: options.name ?? path.basename(path.resolve(options.directory)) };
508
- const { project, created: projectCreated } = await createProject(fetchFn, apiUrl, options.token, options.slug ? { ...projectInput, slug: options.slug } : projectInput);
518
+ const projectInput = { name: options.name ?? path.basename(path.resolve(options.directory)), ...(options.slug ? { slug: options.slug } : {}), ...(options.owner ? { owner: options.owner } : {}) };
519
+ const { project, created: projectCreated } = await createProject(fetchFn, apiUrl, options.token, projectInput);
509
520
  slugHolder.slug = project.slug;
510
521
  slugHolder.created = projectCreated;
522
+ // #1332: a worker-runtime app cannot reach a sibling over its public hostname - the
523
+ // request never leaves the platform's network - and nothing else would tell the author,
524
+ // because the callee answers 200 to everyone outside. Both worker shapes are scanned
525
+ // off bytes the size preflight above already read: the entry from the copy it holds for
526
+ // the upload, a bundle from the stream it was measured on.
527
+ //
528
+ // After the project resolves, not beside those size checks, because the exclusion that
529
+ // makes this warning readable needs the project id: an app's own hostname is the
530
+ // reference the #1237 audit found most of, so without it this fires on nearly every
531
+ // worker deploy. Unlike those checks it refuses nothing, so it has no reason to run
532
+ // before the slug is claimed.
533
+ if (serverEntryBytes) {
534
+ for (const hostname of managedHostnamesIn(new TextDecoder().decode(serverEntryBytes))) {
535
+ managedHostnames.add(hostname);
536
+ }
537
+ }
538
+ const siblings = siblingsOf(managedHostnames, { ownProjectId: project.id });
539
+ if (siblings.length > 0) {
540
+ console.warn(`Warning: ${siblingCallWarning(siblings)}`);
541
+ }
511
542
  const database = options.withDatabase
512
543
  ? await createDatabase(fetchFn, apiUrl, options.token, project.id, {
513
544
  name: options.databaseName ?? `${project.slug}-db`,
@@ -592,7 +623,10 @@ export function measureWorkerEntry(source) {
592
623
  // It deliberately does not stop early once the limit is passed. The messages report exact
593
624
  // byte counts — that precision is what keeps them unambiguous at the boundary — and a floor
594
625
  // like "at least N bytes" would give that up to save a second on an already-failing deploy.
595
- export async function measureWorkerModuleFiles(root, modulePaths) {
626
+ export async function measureWorkerModuleFiles(root, modulePaths,
627
+ // Handed each module's text as the pass streams it. Optional, and skipped entirely when
628
+ // absent, so `--check` still never decodes a bundle it only needs the size of.
629
+ onSource) {
596
630
  const gzip = createGzip();
597
631
  let rawBytes = 0;
598
632
  let compressedBytes = 0;
@@ -613,6 +647,7 @@ export async function measureWorkerModuleFiles(root, modulePaths) {
613
647
  for (const modulePath of modulePaths) {
614
648
  const bytes = await readManifestFile(root, modulePath);
615
649
  rawBytes += bytes.byteLength;
650
+ onSource?.(new TextDecoder().decode(bytes));
616
651
  if (!gzip.write(bytes)) {
617
652
  // Respect backpressure so a large bundle cannot balloon the stream's internal queue.
618
653
  await once(gzip, "drain");
@@ -717,6 +752,12 @@ export async function checkDeployDirectory(options) {
717
752
  const entrySource = await validateWorkerEntrySource(source, serverEntry);
718
753
  warnings.push(...entrySource.warnings);
719
754
  errors.push(...entrySource.errors);
755
+ // The sibling-hostname warning (#1332) deliberately does NOT run here, though this
756
+ // has the source in hand and #645 otherwise wants the two paths agreeing. It needs
757
+ // the project id to tell a sibling's hostname from the app's own, and `--check` runs
758
+ // against a directory, before any project exists - so here it could only report
759
+ // every app's reference to ITSELF, which is the false positive that would teach
760
+ // people to stop reading it.
720
761
  }
721
762
  }
722
763
  // Same server-code budget the real deploy applies to a bundle (#660), measured the same
@@ -1199,11 +1240,13 @@ function readUpdateCheckCache(cachePath) {
1199
1240
  }
1200
1241
  }
1201
1242
  export async function listSites(options) {
1202
- const payload = await apiRequest(options, "/v1/sites", { method: "GET" });
1243
+ const route = options.owner ? `/v1/sites?owner=${encodeURIComponent(options.owner)}` : "/v1/sites";
1244
+ const payload = await apiRequest(options, route, { method: "GET" });
1203
1245
  return { projects: payload.projects };
1204
1246
  }
1205
1247
  export async function deleteSite(options) {
1206
- const payload = await apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}`, {
1248
+ const route = `/v1/sites/${encodeURIComponent(options.site)}${options.owner ? `?owner=${encodeURIComponent(options.owner)}` : ""}`;
1249
+ const payload = await apiRequest(options, route, {
1207
1250
  method: "DELETE",
1208
1251
  body: JSON.stringify({ confirmSlug: options.confirmSlug, ...(options.keepRecord ? { keepRecord: true } : {}) }),
1209
1252
  });
@@ -2154,6 +2197,7 @@ const VALID_DEPLOY_FLAGS = new Set([
2154
2197
  "email",
2155
2198
  "name",
2156
2199
  "slug",
2200
+ "owner",
2157
2201
  "description",
2158
2202
  "environment",
2159
2203
  "database-name",
@@ -2231,7 +2275,8 @@ export function parseArgs(argv) {
2231
2275
  }
2232
2276
  if (command === "apps" || command === "list" || command === "ls" || command === "sites" || command === "projects") {
2233
2277
  const flags = parseFlags(argv);
2234
- return { kind: "apps", ...readBaseApiOptions(flags), json: flags.has("json") };
2278
+ const owner = readStringFlag(flags, "owner");
2279
+ return { kind: "apps", ...readBaseApiOptions(flags), json: flags.has("json"), ...(owner ? { owner } : {}) };
2235
2280
  }
2236
2281
  if (command === "latest" || command === "upgrade" || command === "update") {
2237
2282
  const flags = parseFlags(argv);
@@ -2292,6 +2337,7 @@ export function parseArgs(argv) {
2292
2337
  };
2293
2338
  const name = readStringFlag(flags, "name");
2294
2339
  const slug = readStringFlag(flags, "slug");
2340
+ const owner = readStringFlag(flags, "owner");
2295
2341
  const description = readStringFlag(flags, "description");
2296
2342
  const environment = readStringFlag(flags, "environment");
2297
2343
  const databaseName = readStringFlag(flags, "database-name");
@@ -2307,6 +2353,9 @@ export function parseArgs(argv) {
2307
2353
  if (slug) {
2308
2354
  options.slug = slug;
2309
2355
  }
2356
+ if (owner) {
2357
+ options.owner = owner;
2358
+ }
2310
2359
  let normalizedDescription;
2311
2360
  try {
2312
2361
  normalizedDescription = normalizeDeployDescription(description);
@@ -3944,7 +3993,7 @@ async function main() {
3944
3993
  if (queued) {
3945
3994
  console.log(`Status: ${result.deployment.status}. Run 9pm apps to check when it becomes active.`);
3946
3995
  }
3947
- console.log(`Manage: ${PUBLIC_DASHBOARD_URL}`);
3996
+ console.log(`Manage: ${PUBLIC_DASHBOARD_URL}${command.options.owner ? `?owner=${encodeURIComponent(command.options.owner)}` : ""}`);
3948
3997
  // Gated the same way as the reachability probe above: a programmatic
3949
3998
  // caller injecting fetchFn opted out of the CLI's own network side trips.
3950
3999
  if (!command.options.fetchFn) {
@@ -4144,7 +4193,7 @@ async function main() {
4144
4193
  return;
4145
4194
  }
4146
4195
  if (command.kind === "apps") {
4147
- const result = await listSites({ apiUrl: command.apiUrl, token: command.token });
4196
+ const result = await listSites({ apiUrl: command.apiUrl, token: command.token, ...(command.owner ? { owner: command.owner } : {}) });
4148
4197
  if (command.json) {
4149
4198
  console.log(JSON.stringify(result, null, 2));
4150
4199
  return;
@@ -4395,7 +4444,7 @@ async function runFilesCommand(command) {
4395
4444
  function printUsage() {
4396
4445
  console.log(`Usage:
4397
4446
  9pm --version
4398
- 9pm deploy [directory] --name <site-name> [--slug <slug>] [--description "<note>"] [--with-db] [--with-auth] [--with-email]
4447
+ 9pm deploy [directory] --name <site-name> [--slug <slug>] [--owner <organisation>] [--description "<note>"] [--with-db] [--with-auth] [--with-email]
4399
4448
  9pm deploy [directory] --runtime worker --entry src/index.js [--with-db]
4400
4449
  9pm deploy [directory] --runtime container --port 8080 [--health-path /health] [--region auto] [--with-db] [--always-on] [--volume <gb> | --no-volume] [--cpus <n>] [--memory <size>]
4401
4450
  9pm deploy [directory] framework projects (Next.js, Vite) are detected and built automatically; --no-build skips
@@ -4407,7 +4456,7 @@ function printUsage() {
4407
4456
  9pm logout [--api-url <url>] [--local] revokes the credential server-side, then removes it from this machine (--local skips the revoke)
4408
4457
  9pm doctor [--json]
4409
4458
  9pm whoami [--json]
4410
- 9pm apps [--json]
4459
+ 9pm apps [--owner <organisation>] [--json]
4411
4460
  9pm latest [--json]
4412
4461
  9pm support -m "<what happened>" [--subject <subject>] [--site <app>]
4413
4462
  9pm files list [--project <site>]
@@ -0,0 +1,34 @@
1
+ export declare const DEFAULT_APP_HOST_BASE = "9pm.ai";
2
+ /**
3
+ * Every managed app hostname appearing in `source`, the deploying app's own included.
4
+ *
5
+ * Matched on the HOST portion only - a bare hostname, or one inside a URL - so a path or
6
+ * query string that happens to contain the base domain does not register.
7
+ *
8
+ * Separate from the own-app filter below because the deploy path learns the two at
9
+ * different moments: a `--bundle` build is scanned module by module as the size pass
10
+ * streams it, which happens before the project (and so the id that identifies the app's
11
+ * own hostname) exists. Collecting the handful of matches costs nothing to carry; holding
12
+ * the sources until the project resolves would mean holding the whole bundle.
13
+ */
14
+ export declare function managedHostnamesIn(source: string, options?: {
15
+ appHostBase?: string;
16
+ }): string[];
17
+ /**
18
+ * Of `hostnames`, the ones belonging to some OTHER app.
19
+ *
20
+ * `ownProjectId` is the id of the project being deployed; without it every reference an
21
+ * app makes to ITSELF is reported, which the #1237 audit found to be most of the
22
+ * references on the estate - so the deploy path must not call this before the project
23
+ * resolves.
24
+ */
25
+ export declare function siblingsOf(hostnames: Iterable<string>, options?: {
26
+ appHostBase?: string;
27
+ ownProjectId?: string | null;
28
+ }): string[];
29
+ /** `managedHostnamesIn` and `siblingsOf` in one, for a caller holding the whole source. */
30
+ export declare function siblingHostnamesIn(source: string, options?: {
31
+ appHostBase?: string;
32
+ ownProjectId?: string | null;
33
+ }): string[];
34
+ export declare function siblingCallWarning(hostnames: string[]): string;
@@ -0,0 +1,128 @@
1
+ // AUTO-GENERATED by scripts/generate-cli-package.mjs from the in-repo CLI source -- do not edit.
2
+ // Edit src/cli or src/shared instead; this is regenerated on typecheck/test/build/pack.
3
+ // #1332: a Worker-runtime app must not call a sibling app by its public hostname.
4
+ //
5
+ // A same-zone Worker `fetch()` bypasses Worker routes, so once an app is served only by
6
+ // the `*.9pm.ai/*` wildcard - which, as of 2026-09-21, every app is, none of them holding
7
+ // a Custom Domain any more - such a call gets Cloudflare `1016` while both apps still
8
+ // answer `200` to anyone outside. No external probe sees it, and the app that breaks is
9
+ // not the app that changed.
10
+ //
11
+ // A request from a container's own runtime, or from a browser, is unaffected: those reach
12
+ // the edge like any other client. So this is specifically about server code inside a
13
+ // worker-runtime bundle.
14
+ //
15
+ // A WARNING rather than a refusal, deliberately. A managed hostname has legitimate uses in
16
+ // a bundle - a link handed to the browser, a string in copy, a CSP entry - and refusing on
17
+ // the first false positive teaches people to route around the check rather than read it.
18
+ // The audit on #1237 found every real reference to be a literal, so this catches the
19
+ // mechanically findable case and knowingly misses a hostname arriving from an env var.
20
+ // The platform's own hostnames under the app host base. Server code may call these
21
+ // normally - they are Custom Domains, reached through the edge like any other client - so
22
+ // none of them is a sibling call. Listed explicitly, and checked BEFORE the label pattern,
23
+ // because `custom-origin` satisfies that pattern ("origin" being six alphanumerics): the
24
+ // zone's custom-domain fallback origin would otherwise read as a tester's app, and an app
25
+ // documenting its own domain setup embeds it. The rest escape the pattern only because
26
+ // "staging" is seven characters, which is arithmetic rather than a rule. The apex carries
27
+ // no label at all, so it needs no entry. Kept honest by a test that feeds every hostname
28
+ // the wrangler configs declare through the matcher.
29
+ const PLATFORM_LABELS = new Set([
30
+ "admin",
31
+ "admin-staging",
32
+ "api",
33
+ "api-staging",
34
+ "custom-origin",
35
+ "custom-origin.staging",
36
+ "files",
37
+ "files-staging",
38
+ "staging",
39
+ "www",
40
+ ]);
41
+ // `<slug>-<suffix>[-<marker>]` where the suffix is the last six alphanumerics of the
42
+ // project id, lowercased, and the marker is the environment's label marker - `stg` on
43
+ // staging (`appScriptLabelForProject`). The slug is at least one character and may carry
44
+ // dashes. The suffix is CAPTURED rather than merely matched: it is what tells one app's
45
+ // label from another's, and so what identifies the deploying app's own hostname below.
46
+ const MANAGED_LABEL = /^(?:[a-z0-9]+-)+([a-z0-9]{6})(?:-[a-z0-9]{1,8})?$/;
47
+ // The app host base in production. Declared here rather than taken from cli-config so the
48
+ // tarball needs no new hand-maintained export; callers that serve another base (staging,
49
+ // local) pass it explicitly.
50
+ export const DEFAULT_APP_HOST_BASE = "9pm.ai";
51
+ function normalizeBase(appHostBase) {
52
+ return (appHostBase ?? DEFAULT_APP_HOST_BASE).trim().toLowerCase();
53
+ }
54
+ // The six-character id suffix an app's own label carries (`appScriptLabelForProject`).
55
+ // The own-app exclusion keys on this rather than on the whole hostname because a caller
56
+ // generally cannot build the whole hostname: the slug half is truncated to fit a
57
+ // 63-character DNS label, and by the marker's length again on an environment that adds
58
+ // one - so reproducing it would mean reproducing that arithmetic for an environment the
59
+ // CLI cannot tell it is talking to. Two projects whose ids end in the same six characters
60
+ // would hide one app's warning from the other, which is the cheap direction to be wrong.
61
+ function labelIdSuffix(projectId) {
62
+ return projectId.replace(/[^a-z0-9]/gi, "").slice(-6).toLowerCase();
63
+ }
64
+ /**
65
+ * Every managed app hostname appearing in `source`, the deploying app's own included.
66
+ *
67
+ * Matched on the HOST portion only - a bare hostname, or one inside a URL - so a path or
68
+ * query string that happens to contain the base domain does not register.
69
+ *
70
+ * Separate from the own-app filter below because the deploy path learns the two at
71
+ * different moments: a `--bundle` build is scanned module by module as the size pass
72
+ * streams it, which happens before the project (and so the id that identifies the app's
73
+ * own hostname) exists. Collecting the handful of matches costs nothing to carry; holding
74
+ * the sources until the project resolves would mean holding the whole bundle.
75
+ */
76
+ export function managedHostnamesIn(source, options = {}) {
77
+ const base = normalizeBase(options.appHostBase);
78
+ if (!base) {
79
+ return [];
80
+ }
81
+ // Bounded on both sides by characters that cannot continue a hostname. The right-hand
82
+ // lookahead excludes a DOT as well, so `example.com/my-app-abc123.9pm.ai.txt` - a file
83
+ // on somebody else's domain that happens to contain our base - does not read as a call.
84
+ const pattern = new RegExp(`(?:^|[^a-z0-9.-])([a-z0-9][a-z0-9.-]*)\\.${base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![a-z0-9.-])`, "gi");
85
+ const found = new Set();
86
+ for (const match of source.toLowerCase().matchAll(pattern)) {
87
+ const label = match[1];
88
+ if (label && !PLATFORM_LABELS.has(label) && MANAGED_LABEL.test(label)) {
89
+ found.add(`${label}.${base}`);
90
+ }
91
+ }
92
+ return [...found].sort();
93
+ }
94
+ /**
95
+ * Of `hostnames`, the ones belonging to some OTHER app.
96
+ *
97
+ * `ownProjectId` is the id of the project being deployed; without it every reference an
98
+ * app makes to ITSELF is reported, which the #1237 audit found to be most of the
99
+ * references on the estate - so the deploy path must not call this before the project
100
+ * resolves.
101
+ */
102
+ export function siblingsOf(hostnames, options = {}) {
103
+ const base = normalizeBase(options.appHostBase);
104
+ const ownSuffix = options.ownProjectId ? labelIdSuffix(options.ownProjectId) : "";
105
+ if (!base || !ownSuffix) {
106
+ return [...new Set(hostnames)].sort();
107
+ }
108
+ const siblings = new Set();
109
+ for (const hostname of hostnames) {
110
+ const label = hostname.toLowerCase().endsWith(`.${base}`) ? hostname.slice(0, -(base.length + 1)) : hostname;
111
+ if (MANAGED_LABEL.exec(label)?.[1] !== ownSuffix) {
112
+ siblings.add(hostname);
113
+ }
114
+ }
115
+ return [...siblings].sort();
116
+ }
117
+ /** `managedHostnamesIn` and `siblingsOf` in one, for a caller holding the whole source. */
118
+ export function siblingHostnamesIn(source, options = {}) {
119
+ return siblingsOf(managedHostnamesIn(source, options), options);
120
+ }
121
+ export function siblingCallWarning(hostnames) {
122
+ const named = hostnames.slice(0, 3).join(", ");
123
+ const rest = hostnames.length > 3 ? ` (and ${hostnames.length - 3} more)` : "";
124
+ return (`Server code references another app's hostname (${named}${rest}). ` +
125
+ "A worker-runtime app cannot reach a sibling app over its public hostname - the request " +
126
+ "does not leave the platform's network and fails with an edge error, even though the " +
127
+ "other app is up. Call it from the browser instead, or from a container's own runtime.");
128
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ninepm",
3
- "version": "0.56.0",
3
+ "version": "0.58.0",
4
4
  "description": "CLI for deploying built apps to 9pm.ai.",
5
5
  "homepage": "https://9pm.ai",
6
6
  "keywords": [