@swell/cli 2.5.8 → 2.7.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.
Files changed (55) hide show
  1. package/dist/commands/api/delete.d.ts +1 -0
  2. package/dist/commands/api/delete.js +3 -1
  3. package/dist/commands/api/get.d.ts +1 -0
  4. package/dist/commands/api/get.js +3 -1
  5. package/dist/commands/api/post.d.ts +1 -0
  6. package/dist/commands/api/post.js +3 -1
  7. package/dist/commands/api/put.d.ts +1 -0
  8. package/dist/commands/api/put.js +3 -1
  9. package/dist/commands/app/dev.js +2 -5
  10. package/dist/commands/app/frontend/dev.js +18 -0
  11. package/dist/commands/inspect/content.d.ts +25 -14
  12. package/dist/commands/inspect/content.js +34 -144
  13. package/dist/commands/inspect/functions.d.ts +48 -0
  14. package/dist/commands/inspect/functions.js +83 -0
  15. package/dist/commands/inspect/index.js +8 -7
  16. package/dist/commands/inspect/models.d.ts +17 -2
  17. package/dist/commands/inspect/models.js +114 -47
  18. package/dist/commands/inspect/notifications.d.ts +31 -0
  19. package/dist/commands/inspect/notifications.js +125 -0
  20. package/dist/commands/inspect/settings.d.ts +28 -0
  21. package/dist/commands/inspect/settings.js +82 -0
  22. package/dist/commands/inspect/webhooks.d.ts +34 -0
  23. package/dist/commands/inspect/webhooks.js +61 -0
  24. package/dist/create-app-command.d.ts +7 -0
  25. package/dist/create-app-command.js +66 -3
  26. package/dist/inspect-resource-command.d.ts +91 -0
  27. package/dist/inspect-resource-command.js +232 -0
  28. package/dist/lib/apps/index.d.ts +2 -1
  29. package/dist/lib/apps/index.js +43 -6
  30. package/dist/lib/apps/inspect-scope.d.ts +58 -0
  31. package/dist/lib/apps/inspect-scope.js +60 -0
  32. package/dist/lib/apps/object-id.d.ts +7 -0
  33. package/dist/lib/apps/object-id.js +9 -0
  34. package/dist/lib/apps/paths.js +8 -1
  35. package/dist/lib/apps/resolve.d.ts +16 -0
  36. package/dist/lib/apps/resolve.js +39 -0
  37. package/dist/lib/apps/slug.d.ts +29 -0
  38. package/dist/lib/apps/slug.js +12 -0
  39. package/dist/lib/create/tests/templates/mock-request.js +12 -1
  40. package/dist/lib/create/tests/templates/setup-globals.js +2 -0
  41. package/dist/lib/inspect/content.d.ts +39 -0
  42. package/dist/lib/inspect/content.js +76 -0
  43. package/dist/lib/inspect/notifications.d.ts +115 -0
  44. package/dist/lib/inspect/notifications.js +173 -0
  45. package/dist/lib/inspect/settings.d.ts +61 -0
  46. package/dist/lib/inspect/settings.js +56 -0
  47. package/dist/lib/inspect/table.d.ts +29 -0
  48. package/dist/lib/inspect/table.js +61 -0
  49. package/dist/lib/swell-function-wrapper.d.ts +7 -3
  50. package/dist/lib/swell-function-wrapper.js +53 -15
  51. package/dist/push-app-command.js +3 -2
  52. package/dist/swell-api-command.d.ts +2 -4
  53. package/dist/swell-api-command.js +58 -35
  54. package/oclif.manifest.json +364 -35
  55. package/package.json +1 -1
@@ -0,0 +1,60 @@
1
+ import { HEX24 } from './object-id.js';
2
+ export class ScopeError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = 'ScopeError';
6
+ }
7
+ }
8
+ /**
9
+ * Resolve the scope of an `inspect` subcommand.
10
+ *
11
+ * Grammar:
12
+ * (no --app) → global listing, no filter
13
+ * --app=<slug> → filter by the named app
14
+ * --app=. → filter by the app in the current directory's swell.json
15
+ *
16
+ * The `.` sentinel is the only place swell.json influences behavior; the
17
+ * default is always global so the same invocation produces the same output
18
+ * regardless of cwd.
19
+ */
20
+ export async function resolveInspectScope(ctx, flags = {}) {
21
+ if (!flags.app) {
22
+ return { query: {} };
23
+ }
24
+ if (flags.app === '.') {
25
+ const slug = await ctx.readCurrentAppSlug();
26
+ if (!slug) {
27
+ throw new ScopeError(`--app=. requires a swell.json with an 'id' field in the current directory. Pass --app=<slug> or cd into an app.`);
28
+ }
29
+ const appId = await ctx.resolveAppId(slug);
30
+ return { appId, appSlug: slug, query: { app_id: appId } };
31
+ }
32
+ const appId = await ctx.resolveAppId(flags.app);
33
+ return { appId, appSlug: flags.app, query: { app_id: appId } };
34
+ }
35
+ /**
36
+ * Classify an inspect-subcommand identifier argument.
37
+ *
38
+ * - `id`: 24-char hex (Mongo ObjectId)
39
+ * - `slug`: dotted form `app.<appPart>.<name>` where appPart can be either
40
+ * an ObjectId or a public/private app slug
41
+ * - `name`: bare identifier (letters, digits, hyphen, underscore) —
42
+ * requires app context to resolve
43
+ *
44
+ * Resources whose stored ids are non-hex dotted strings (notifications)
45
+ * handle dispatch in their own `showDetail` override; the base classifier
46
+ * stays narrow.
47
+ */
48
+ export function classifyIdentifier(identifier) {
49
+ if (HEX24.test(identifier)) {
50
+ return { kind: 'id', id: identifier };
51
+ }
52
+ const slugMatch = identifier.match(/^app\.([^.]+)\.(.+)$/);
53
+ if (slugMatch) {
54
+ return { kind: 'slug', appPart: slugMatch[1], name: slugMatch[2] };
55
+ }
56
+ if (/^[\w-]+$/i.test(identifier)) {
57
+ return { kind: 'name', name: identifier };
58
+ }
59
+ return { kind: 'invalid', input: identifier };
60
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Mongo ObjectId shape — 24 hex chars, case-insensitive. The CLI accepts
3
+ * either-case input (curl/jq users tend to lowercase, admin URLs sometimes
4
+ * upper). All inspect grammars use the same pattern, so it lives here.
5
+ */
6
+ export declare const HEX24: RegExp;
7
+ export declare function isObjectId(value: string): boolean;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Mongo ObjectId shape — 24 hex chars, case-insensitive. The CLI accepts
3
+ * either-case input (curl/jq users tend to lowercase, admin URLs sometimes
4
+ * upper). All inspect grammars use the same pattern, so it lives here.
5
+ */
6
+ export const HEX24 = /^[\da-f]{24}$/i;
7
+ export function isObjectId(value) {
8
+ return HEX24.test(value);
9
+ }
@@ -9,13 +9,20 @@ function globOptions(appPath, options) {
9
9
  gitignore: true,
10
10
  ...options,
11
11
  expandDirectories: true,
12
- // Explicitly ignore node_modules and lock files just in case
12
+ // .dev.vars can hold real credentials ignored here in case the
13
+ // user's .gitignore doesn't cover it.
13
14
  ignore: [
14
15
  '**/.swellrc',
15
16
  '**/.git/**',
16
17
  '**/node_modules/**',
17
18
  '**/package-lock.json',
18
19
  '**/yarn.lock',
20
+ '**/pnpm-lock.yaml',
21
+ '**/bun.lockb',
22
+ '**/bun.lock',
23
+ '**/.wrangler/**',
24
+ '**/.dev.vars',
25
+ '**/.dev.vars.*',
19
26
  ...(options?.ignore || []),
20
27
  ],
21
28
  };
@@ -0,0 +1,16 @@
1
+ import Api from '../api.js';
2
+ /**
3
+ * Resolve an app's ObjectId from a public slug or private slug; pass through
4
+ * a value that already looks like an ObjectId.
5
+ *
6
+ * Priority mirrors schema-api-server installer.js:3596 `getAppBySlugId`:
7
+ * private match (`_<slug>`) wins over public match. For typical apps the
8
+ * order is invisible (a single record matches both branches), but it
9
+ * matters when two apps share a slug across publish state.
10
+ */
11
+ export declare function resolveAppId(api: Api, appIdOrSlug: string): Promise<string>;
12
+ /**
13
+ * Build a map of installed-app ObjectId -> friendly slug, using the shared
14
+ * server-canonical slug priority (see `slug.ts`).
15
+ */
16
+ export declare function buildAppSlugMap(api: Api): Promise<Record<string, string>>;
@@ -0,0 +1,39 @@
1
+ import { isObjectId } from './object-id.js';
2
+ import { slugFromInstalledApp } from './slug.js';
3
+ /**
4
+ * Resolve an app's ObjectId from a public slug or private slug; pass through
5
+ * a value that already looks like an ObjectId.
6
+ *
7
+ * Priority mirrors schema-api-server installer.js:3596 `getAppBySlugId`:
8
+ * private match (`_<slug>`) wins over public match. For typical apps the
9
+ * order is invisible (a single record matches both branches), but it
10
+ * matters when two apps share a slug across publish state.
11
+ */
12
+ export async function resolveAppId(api, appIdOrSlug) {
13
+ if (isObjectId(appIdOrSlug)) {
14
+ return appIdOrSlug;
15
+ }
16
+ const installedApps = await api.get({ adminPath: `/client/apps` });
17
+ const records = (installedApps?.results ?? []);
18
+ const privateMatch = records.find((a) => a.app_private_id === `_${appIdOrSlug}`);
19
+ const app = privateMatch ?? records.find((a) => a.app_public_id === appIdOrSlug);
20
+ if (!app) {
21
+ throw new Error(`App '${appIdOrSlug}' not found`);
22
+ }
23
+ return app.app_id;
24
+ }
25
+ /**
26
+ * Build a map of installed-app ObjectId -> friendly slug, using the shared
27
+ * server-canonical slug priority (see `slug.ts`).
28
+ */
29
+ export async function buildAppSlugMap(api) {
30
+ const installedApps = await api.get({ adminPath: `/client/apps` });
31
+ const map = {};
32
+ for (const a of (installedApps?.results ?? [])) {
33
+ const slug = slugFromInstalledApp(a);
34
+ if (a.app_id && slug) {
35
+ map[a.app_id] = slug;
36
+ }
37
+ }
38
+ return map;
39
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Compute an app's display slug.
3
+ *
4
+ * Mirrors the server-canonical priority in
5
+ * schema-api-server/api/admin/features/apps/installer.js:213 — private_id
6
+ * (with the leading underscore stripped) wins over public_id. The orders
7
+ * agree for the typical case where `private_id === '_' + public_id`, but
8
+ * diverge for theme-collision suffixes (`_my_theme_1`) and any app where
9
+ * `public_id` was set independently of `private_id`. Following the server
10
+ * keeps CLI labels in sync with what the admin UI shows for the same row.
11
+ *
12
+ * No id fallback: an app with neither slug returns `undefined`. Callers
13
+ * that want a "show something" guarantee (e.g. the push-app picker) should
14
+ * append `?? app.id` themselves; the slug map deliberately filters such
15
+ * records out so downstream code can flag them as `<not resolved>`.
16
+ */
17
+ export interface SlugSource {
18
+ privateId?: string;
19
+ publicId?: string;
20
+ }
21
+ export declare function appSlug(source: SlugSource): string | undefined;
22
+ export declare function slugFromApp(app: {
23
+ private_id?: string;
24
+ public_id?: string;
25
+ }): string | undefined;
26
+ export declare function slugFromInstalledApp(rec: {
27
+ app_private_id?: string;
28
+ app_public_id?: string;
29
+ }): string | undefined;
@@ -0,0 +1,12 @@
1
+ export function appSlug(source) {
2
+ return source.privateId?.replace(/^_/, '') || source.publicId;
3
+ }
4
+ export function slugFromApp(app) {
5
+ return appSlug({ privateId: app.private_id, publicId: app.public_id });
6
+ }
7
+ export function slugFromInstalledApp(rec) {
8
+ return appSlug({
9
+ privateId: rec.app_private_id,
10
+ publicId: rec.app_public_id,
11
+ });
12
+ }
@@ -97,7 +97,18 @@ export function createMockRequest(options: MockRequestOptions = {}): SwellReques
97
97
  const targetAppId =
98
98
  typeof idOrValues === "string" ? idOrValues : resolvedAppId;
99
99
  const appValues = typeof idOrValues === "string" ? values : idOrValues;
100
- if (!targetAppId || !appValues) return undefined;
100
+ if (!targetAppId) {
101
+ throw new Error("appValues: missing app id (req.appId is empty)");
102
+ }
103
+ if (
104
+ typeof appValues !== "object" ||
105
+ appValues === null ||
106
+ Object.getPrototypeOf(appValues) !== Object.prototype
107
+ ) {
108
+ throw new Error(
109
+ "appValues: values must be a plain object (arrays, class instances, null, and primitives are not allowed)"
110
+ );
111
+ }
101
112
  return {
102
113
  $app: {
103
114
  [targetAppId]: appValues,
@@ -2,6 +2,7 @@ export function setupGlobalsTemplate() {
2
2
  return `\
3
3
  class SwellErrorImpl extends Error {
4
4
  status: number;
5
+ body?: unknown;
5
6
 
6
7
  constructor(message: string | object, options: { status?: number } = {}) {
7
8
  const text =
@@ -12,6 +13,7 @@ class SwellErrorImpl extends Error {
12
13
  super(text);
13
14
  this.name = "SwellError";
14
15
  this.status = options.status ?? 500;
16
+ this.body = typeof message === "string" ? undefined : message;
15
17
  }
16
18
  }
17
19
 
@@ -0,0 +1,39 @@
1
+ import { GroupInfo } from './table.js';
2
+ export interface ParsedContentId {
3
+ middle: string;
4
+ name: string;
5
+ prefix: 'app' | 'custom';
6
+ }
7
+ /**
8
+ * Parse a content view identifier of the form `app.<appPart>.<name>` or
9
+ * `custom.<source>.<name>`. Returns null for anything that doesn't match.
10
+ */
11
+ export declare function parseContentId(id: string): ParsedContentId | null;
12
+ /**
13
+ * Produce the column-1 paste-back key for a content view. Rewrites
14
+ * `app.<hex>.<name>` to `app.<slug>.<name>` when the slug is known; passes
15
+ * `custom.*` and unresolvable app ids through unchanged (both remain valid
16
+ * paste-back forms against the detail endpoint).
17
+ */
18
+ export declare function resolveContentKey(id: string, appSlugById: Record<string, string>): string;
19
+ /**
20
+ * Group a content view record for the vertical split. Records with no
21
+ * `app_id` belong to the platform's `custom.*` source and land in a
22
+ * single `<custom>` section at the top of the output.
23
+ */
24
+ export declare function groupContentRecord(record: {
25
+ app_id?: string;
26
+ }, appSlugById: Record<string, string>): GroupInfo;
27
+ /**
28
+ * Translate a user-supplied content identifier into the unresolved API
29
+ * form. Accepts:
30
+ * - `custom.<source>.<name>` → unchanged
31
+ * - `app.<hex>.<name>` → unchanged (already unresolved form)
32
+ * - `app.<slug>.<name>` → slug resolved to hex
33
+ * - bare `<name>` → combined with scope.appId to `app.<hex>.<name>`
34
+ *
35
+ * `resolveAppId` is injected so this function is testable without a live API.
36
+ */
37
+ export declare function reverseResolveContentId(identifier: string, scope: {
38
+ appId?: string;
39
+ }, resolveAppId: (slug: string) => Promise<string>): Promise<string>;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Parse a content view identifier of the form `app.<appPart>.<name>` or
3
+ * `custom.<source>.<name>`. Returns null for anything that doesn't match.
4
+ */
5
+ export function parseContentId(id) {
6
+ const match = id.match(/^(app|custom)\.([^.]+)\.(.+)$/);
7
+ if (!match)
8
+ return null;
9
+ return {
10
+ prefix: match[1],
11
+ middle: match[2],
12
+ name: match[3],
13
+ };
14
+ }
15
+ /**
16
+ * Produce the column-1 paste-back key for a content view. Rewrites
17
+ * `app.<hex>.<name>` to `app.<slug>.<name>` when the slug is known; passes
18
+ * `custom.*` and unresolvable app ids through unchanged (both remain valid
19
+ * paste-back forms against the detail endpoint).
20
+ */
21
+ export function resolveContentKey(id, appSlugById) {
22
+ const parsed = parseContentId(id);
23
+ if (!parsed)
24
+ return id;
25
+ if (parsed.prefix === 'custom')
26
+ return id;
27
+ const slug = appSlugById[parsed.middle] ?? parsed.middle;
28
+ return `app.${slug}.${parsed.name}`;
29
+ }
30
+ /**
31
+ * Group a content view record for the vertical split. Records with no
32
+ * `app_id` belong to the platform's `custom.*` source and land in a
33
+ * single `<custom>` section at the top of the output.
34
+ */
35
+ export function groupContentRecord(record, appSlugById) {
36
+ if (!record.app_id) {
37
+ return { slug: '<custom>', label: 'custom', order: 0 };
38
+ }
39
+ const resolved = appSlugById[record.app_id];
40
+ if (!resolved) {
41
+ return { slug: '<not resolved>', order: 2 };
42
+ }
43
+ return { slug: resolved };
44
+ }
45
+ /**
46
+ * Translate a user-supplied content identifier into the unresolved API
47
+ * form. Accepts:
48
+ * - `custom.<source>.<name>` → unchanged
49
+ * - `app.<hex>.<name>` → unchanged (already unresolved form)
50
+ * - `app.<slug>.<name>` → slug resolved to hex
51
+ * - bare `<name>` → combined with scope.appId to `app.<hex>.<name>`
52
+ *
53
+ * `resolveAppId` is injected so this function is testable without a live API.
54
+ */
55
+ export async function reverseResolveContentId(identifier, scope, resolveAppId) {
56
+ if (/^custom\.[^.]+\..+$/.test(identifier)) {
57
+ return identifier;
58
+ }
59
+ if (/^app\.[\da-f]{24}\..+$/i.test(identifier)) {
60
+ return identifier;
61
+ }
62
+ const appMatch = identifier.match(/^app\.([^.]+)\.(.+)$/);
63
+ if (appMatch) {
64
+ const appId = await resolveAppId(appMatch[1]);
65
+ return `app.${appId}.${appMatch[2]}`;
66
+ }
67
+ if (/^[\w-]+$/i.test(identifier)) {
68
+ if (!scope.appId) {
69
+ throw new Error(`Bare content name '${identifier}' requires --app=<slug> or --app=. to scope. ` +
70
+ `Alternatively, pass a full identifier (app.<app>.<name> or custom.<source>.<name>).`);
71
+ }
72
+ return `app.${scope.appId}.${identifier}`;
73
+ }
74
+ throw new Error(`Invalid content identifier '${identifier}'. ` +
75
+ `Expected bare name, app.<app>.<name>, or custom.<source>.<name>.`);
76
+ }
@@ -0,0 +1,115 @@
1
+ import { GroupInfo } from './table.js';
2
+ export interface NotificationRecord {
3
+ api?: string;
4
+ app_id?: string;
5
+ enabled?: boolean;
6
+ id?: string;
7
+ model?: string;
8
+ name?: string;
9
+ v2?: boolean;
10
+ }
11
+ export type ParsedNotificationKey = {
12
+ id: string;
13
+ kind: 'hex';
14
+ } | {
15
+ id: string;
16
+ kind: 'system_id';
17
+ } | {
18
+ kind: 'app_full';
19
+ model: string;
20
+ name: string;
21
+ slug: string;
22
+ } | {
23
+ kind: 'app_short';
24
+ name: string;
25
+ slug: string;
26
+ } | {
27
+ kind: 'bare';
28
+ name: string;
29
+ } | {
30
+ input: string;
31
+ kind: 'invalid';
32
+ };
33
+ /**
34
+ * Translate a stored model path into its display form.
35
+ *
36
+ * apps/<own-hex>/<x> → <x> (own-app prefix is implicit in the slug)
37
+ * apps/<other-hex>/<x> → apps/<other-slug>/<x>
38
+ * apps/<unknown-hex>/<x> → unchanged (preserve a paste-back form)
39
+ * <built-in-model> → unchanged
40
+ *
41
+ * Stripping `apps/<own>/` keeps the common case readable; the rare collision
42
+ * with a same-named built-in (e.g. an app model literally named
43
+ * `subscriptions`) is recovered by `expandModelCandidates` at lookup time.
44
+ */
45
+ export declare function normalizeModelForDisplay(model: string, ownAppId: string | undefined, appSlugById: Record<string, string>): string;
46
+ /**
47
+ * Produce the column-1 paste-back key for a notification.
48
+ *
49
+ * System (no app_id): record.id verbatim — already in `com.<model>.<name>` form.
50
+ * App with model+name: `app.<slug>.<model-display>.<name>`
51
+ * Anything else: record.id (degenerate, no meaningful slug)
52
+ *
53
+ * Notification identity is `(api, model, name, app_id?)`; collapsing on
54
+ * `(app_id, name)` alone caused two `notify_me.back-in-stock` rows
55
+ * (different `model`) to render identically.
56
+ */
57
+ export declare function formatNotificationKey(record: NotificationRecord, appSlugById: Record<string, string>): string;
58
+ /**
59
+ * Group a notification record. System rows (no app_id) are not store data —
60
+ * they belong to the platform and render under a `system` divider at the top.
61
+ */
62
+ export declare function groupNotificationRecord(record: NotificationRecord, appSlugById: Record<string, string>): GroupInfo;
63
+ /**
64
+ * Classify a notification identifier. The base `classifyIdentifier` collapses
65
+ * every dot-segment after `app.<part>.` into `name`, which loses the model.
66
+ * Notifications need a model-aware split, so they bypass the base classifier.
67
+ *
68
+ * Recognised shapes:
69
+ * - 24-char hex → hex (delegate to base GET-by-id)
70
+ * - com.<rest> → system_id (canonical platform id)
71
+ * - app.<slug>.<model>.<name> → app_full (triplet lookup)
72
+ * - app.<slug>.<name> → app_short (3-part legacy; ambiguous)
73
+ * - bare <name> → bare (requires --app= scope; ambiguous)
74
+ * - anything else → invalid
75
+ *
76
+ * Multi-segment names (e.g. `unpaid.v2`) only appear in system notifications,
77
+ * which arrive via the `com.*` form and never go through `app.*` parsing.
78
+ */
79
+ export declare function parseNotificationKey(input: string): ParsedNotificationKey;
80
+ /**
81
+ * Render the disambiguation error body when a `(app_id, name)` query returns
82
+ * multiple notifications. Caller fetches with `limit = displayLimit + 1` so
83
+ * we can detect overflow without paging through the whole result set; if
84
+ * `results.length > displayLimit`, the surplus rows are dropped and a hint
85
+ * line is appended directing the user to the full key form.
86
+ */
87
+ export declare function formatDisambiguationError(results: NotificationRecord[], appSlugById: Record<string, string>, identifier: string, displayLimit?: number): string;
88
+ /**
89
+ * Build the `template` value used on send records at `/notifications` from a
90
+ * manifest record's fields. Three shapes:
91
+ *
92
+ * System (no app_id): <model>.<name>
93
+ * App on a standard model: app_<app_id>.<model>.<name>
94
+ * App on a custom (apps/<hex>/x) model: app_<app_id>.apps_<hex>_<x>.<name>
95
+ *
96
+ * The custom-model branch transforms slashes to underscores in the model
97
+ * segment.
98
+ *
99
+ * V2 system notifications have `name` literally ending in `.v2` (mirroring the
100
+ * `id` and the `v2: true` flag), but the send-record `template` strips that
101
+ * suffix. App notifications carry `v2: true` without the suffix in `name`, so
102
+ * they pass through unchanged.
103
+ *
104
+ * Returns undefined when `model` or `name` is missing.
105
+ */
106
+ export declare function buildNotificationTemplate(record: NotificationRecord): string | undefined;
107
+ /**
108
+ * Expand a display-form model into the candidate stored values to try at
109
+ * lookup time. A bare display token can mean either:
110
+ * - the literal built-in model (e.g. `subscriptions`)
111
+ * - an own-app model whose `apps/<own>/` prefix was stripped for display
112
+ * so we try both. `apps/<slug>/x` is resolved to `apps/<hex>/x`; an already-hex
113
+ * `apps/<hex>/x` passes through unchanged.
114
+ */
115
+ export declare function expandModelCandidates(modelDisplay: string, appId: string, resolveAppId: (slug: string) => Promise<string>): Promise<string[]>;
@@ -0,0 +1,173 @@
1
+ import { HEX24 } from '../apps/object-id.js';
2
+ const APPS_MODEL = /^apps\/([^/]+)\/(.+)$/;
3
+ /**
4
+ * Translate a stored model path into its display form.
5
+ *
6
+ * apps/<own-hex>/<x> → <x> (own-app prefix is implicit in the slug)
7
+ * apps/<other-hex>/<x> → apps/<other-slug>/<x>
8
+ * apps/<unknown-hex>/<x> → unchanged (preserve a paste-back form)
9
+ * <built-in-model> → unchanged
10
+ *
11
+ * Stripping `apps/<own>/` keeps the common case readable; the rare collision
12
+ * with a same-named built-in (e.g. an app model literally named
13
+ * `subscriptions`) is recovered by `expandModelCandidates` at lookup time.
14
+ */
15
+ export function normalizeModelForDisplay(model, ownAppId, appSlugById) {
16
+ const match = model.match(APPS_MODEL);
17
+ if (!match)
18
+ return model;
19
+ const [, hex, rest] = match;
20
+ if (ownAppId && hex === ownAppId)
21
+ return rest;
22
+ const slug = appSlugById[hex];
23
+ return slug ? `apps/${slug}/${rest}` : model;
24
+ }
25
+ /**
26
+ * Produce the column-1 paste-back key for a notification.
27
+ *
28
+ * System (no app_id): record.id verbatim — already in `com.<model>.<name>` form.
29
+ * App with model+name: `app.<slug>.<model-display>.<name>`
30
+ * Anything else: record.id (degenerate, no meaningful slug)
31
+ *
32
+ * Notification identity is `(api, model, name, app_id?)`; collapsing on
33
+ * `(app_id, name)` alone caused two `notify_me.back-in-stock` rows
34
+ * (different `model`) to render identically.
35
+ */
36
+ export function formatNotificationKey(record, appSlugById) {
37
+ if (!record.app_id) {
38
+ return record.id ?? '-';
39
+ }
40
+ if (!record.name || !record.model) {
41
+ return record.id ?? '-';
42
+ }
43
+ const slug = appSlugById[record.app_id] ?? record.app_id;
44
+ const modelDisplay = normalizeModelForDisplay(record.model, record.app_id, appSlugById);
45
+ return `app.${slug}.${modelDisplay}.${record.name}`;
46
+ }
47
+ /**
48
+ * Group a notification record. System rows (no app_id) are not store data —
49
+ * they belong to the platform and render under a `system` divider at the top.
50
+ */
51
+ export function groupNotificationRecord(record, appSlugById) {
52
+ if (!record.app_id) {
53
+ return { slug: '<system>', label: 'system', order: 0 };
54
+ }
55
+ const resolved = appSlugById[record.app_id];
56
+ if (!resolved) {
57
+ return { slug: '<not resolved>', order: 2 };
58
+ }
59
+ return { slug: resolved };
60
+ }
61
+ /**
62
+ * Classify a notification identifier. The base `classifyIdentifier` collapses
63
+ * every dot-segment after `app.<part>.` into `name`, which loses the model.
64
+ * Notifications need a model-aware split, so they bypass the base classifier.
65
+ *
66
+ * Recognised shapes:
67
+ * - 24-char hex → hex (delegate to base GET-by-id)
68
+ * - com.<rest> → system_id (canonical platform id)
69
+ * - app.<slug>.<model>.<name> → app_full (triplet lookup)
70
+ * - app.<slug>.<name> → app_short (3-part legacy; ambiguous)
71
+ * - bare <name> → bare (requires --app= scope; ambiguous)
72
+ * - anything else → invalid
73
+ *
74
+ * Multi-segment names (e.g. `unpaid.v2`) only appear in system notifications,
75
+ * which arrive via the `com.*` form and never go through `app.*` parsing.
76
+ */
77
+ export function parseNotificationKey(input) {
78
+ if (HEX24.test(input)) {
79
+ return { kind: 'hex', id: input };
80
+ }
81
+ if (input.startsWith('com.')) {
82
+ return { kind: 'system_id', id: input };
83
+ }
84
+ if (input.startsWith('app.')) {
85
+ const parts = input.split('.');
86
+ // Reject any post-prefix empty segment (`app..foo`, `app.foo.`, etc.).
87
+ // Without this, `app..foo` would classify as `app_short` with an empty
88
+ // slug and dispatch a useless `/client/apps` lookup.
89
+ const hasEmptySegment = parts.some((p, i) => i > 0 && p.length === 0);
90
+ if (hasEmptySegment) {
91
+ return { kind: 'invalid', input };
92
+ }
93
+ if (parts.length === 3) {
94
+ return { kind: 'app_short', slug: parts[1], name: parts[2] };
95
+ }
96
+ if (parts.length >= 4) {
97
+ const slug = parts[1];
98
+ const name = parts.at(-1);
99
+ const model = parts.slice(2, -1).join('.');
100
+ return { kind: 'app_full', slug, model, name };
101
+ }
102
+ return { kind: 'invalid', input };
103
+ }
104
+ if (/^[\w-]+$/.test(input)) {
105
+ return { kind: 'bare', name: input };
106
+ }
107
+ return { kind: 'invalid', input };
108
+ }
109
+ /**
110
+ * Render the disambiguation error body when a `(app_id, name)` query returns
111
+ * multiple notifications. Caller fetches with `limit = displayLimit + 1` so
112
+ * we can detect overflow without paging through the whole result set; if
113
+ * `results.length > displayLimit`, the surplus rows are dropped and a hint
114
+ * line is appended directing the user to the full key form.
115
+ */
116
+ export function formatDisambiguationError(results, appSlugById, identifier, displayLimit = 10) {
117
+ const truncated = results.length > displayLimit;
118
+ const visible = truncated ? results.slice(0, displayLimit) : results;
119
+ const lines = visible.map((r) => ` ${formatNotificationKey(r, appSlugById)}`);
120
+ if (truncated) {
121
+ lines.push(' … (+more candidates; pass the full key)');
122
+ }
123
+ return `Multiple notifications match '${identifier}'. Use the full key:\n${lines.join('\n')}`;
124
+ }
125
+ /**
126
+ * Build the `template` value used on send records at `/notifications` from a
127
+ * manifest record's fields. Three shapes:
128
+ *
129
+ * System (no app_id): <model>.<name>
130
+ * App on a standard model: app_<app_id>.<model>.<name>
131
+ * App on a custom (apps/<hex>/x) model: app_<app_id>.apps_<hex>_<x>.<name>
132
+ *
133
+ * The custom-model branch transforms slashes to underscores in the model
134
+ * segment.
135
+ *
136
+ * V2 system notifications have `name` literally ending in `.v2` (mirroring the
137
+ * `id` and the `v2: true` flag), but the send-record `template` strips that
138
+ * suffix. App notifications carry `v2: true` without the suffix in `name`, so
139
+ * they pass through unchanged.
140
+ *
141
+ * Returns undefined when `model` or `name` is missing.
142
+ */
143
+ export function buildNotificationTemplate(record) {
144
+ if (!record.model || !record.name) {
145
+ return undefined;
146
+ }
147
+ const name = record.v2 === true && record.name.endsWith('.v2')
148
+ ? record.name.slice(0, -'.v2'.length)
149
+ : record.name;
150
+ const match = record.model.match(APPS_MODEL);
151
+ const modelSegment = match ? `apps_${match[1]}_${match[2]}` : record.model;
152
+ const prefix = record.app_id ? `app_${record.app_id}.` : '';
153
+ return `${prefix}${modelSegment}.${name}`;
154
+ }
155
+ /**
156
+ * Expand a display-form model into the candidate stored values to try at
157
+ * lookup time. A bare display token can mean either:
158
+ * - the literal built-in model (e.g. `subscriptions`)
159
+ * - an own-app model whose `apps/<own>/` prefix was stripped for display
160
+ * so we try both. `apps/<slug>/x` is resolved to `apps/<hex>/x`; an already-hex
161
+ * `apps/<hex>/x` passes through unchanged.
162
+ */
163
+ export async function expandModelCandidates(modelDisplay, appId, resolveAppId) {
164
+ const match = modelDisplay.match(APPS_MODEL);
165
+ if (match) {
166
+ const [, sluglike, rest] = match;
167
+ if (HEX24.test(sluglike))
168
+ return [modelDisplay];
169
+ const hex = await resolveAppId(sluglike);
170
+ return [`apps/${hex}/${rest}`];
171
+ }
172
+ return [modelDisplay, `apps/${appId}/${modelDisplay}`];
173
+ }