@swell/cli 2.6.0 → 2.7.1

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 (46) hide show
  1. package/dist/commands/app/frontend/dev.js +18 -0
  2. package/dist/commands/inspect/content.d.ts +25 -14
  3. package/dist/commands/inspect/content.js +34 -144
  4. package/dist/commands/inspect/extensions.d.ts +49 -0
  5. package/dist/commands/inspect/extensions.js +424 -0
  6. package/dist/commands/inspect/functions.d.ts +48 -0
  7. package/dist/commands/inspect/functions.js +83 -0
  8. package/dist/commands/inspect/index.js +8 -7
  9. package/dist/commands/inspect/models.d.ts +17 -2
  10. package/dist/commands/inspect/models.js +114 -47
  11. package/dist/commands/inspect/notifications.d.ts +31 -0
  12. package/dist/commands/inspect/notifications.js +125 -0
  13. package/dist/commands/inspect/settings.d.ts +28 -0
  14. package/dist/commands/inspect/settings.js +82 -0
  15. package/dist/commands/inspect/webhooks.d.ts +34 -0
  16. package/dist/commands/inspect/webhooks.js +61 -0
  17. package/dist/create-app-command.d.ts +7 -0
  18. package/dist/create-app-command.js +80 -9
  19. package/dist/inspect-resource-command.d.ts +91 -0
  20. package/dist/inspect-resource-command.js +232 -0
  21. package/dist/lib/apps/index.d.ts +2 -1
  22. package/dist/lib/apps/index.js +43 -6
  23. package/dist/lib/apps/inspect-scope.d.ts +58 -0
  24. package/dist/lib/apps/inspect-scope.js +60 -0
  25. package/dist/lib/apps/object-id.d.ts +7 -0
  26. package/dist/lib/apps/object-id.js +9 -0
  27. package/dist/lib/apps/paths.js +8 -1
  28. package/dist/lib/apps/resolve.d.ts +16 -0
  29. package/dist/lib/apps/resolve.js +39 -0
  30. package/dist/lib/apps/slug.d.ts +29 -0
  31. package/dist/lib/apps/slug.js +12 -0
  32. package/dist/lib/inspect/content.d.ts +39 -0
  33. package/dist/lib/inspect/content.js +76 -0
  34. package/dist/lib/inspect/extensions.d.ts +267 -0
  35. package/dist/lib/inspect/extensions.js +690 -0
  36. package/dist/lib/inspect/notifications.d.ts +115 -0
  37. package/dist/lib/inspect/notifications.js +173 -0
  38. package/dist/lib/inspect/settings.d.ts +61 -0
  39. package/dist/lib/inspect/settings.js +56 -0
  40. package/dist/lib/inspect/table.d.ts +29 -0
  41. package/dist/lib/inspect/table.js +61 -0
  42. package/dist/push-app-command.js +3 -2
  43. package/dist/swell-api-command.d.ts +0 -4
  44. package/dist/swell-api-command.js +2 -18
  45. package/oclif.manifest.json +392 -35
  46. package/package.json +1 -1
@@ -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
+ }
@@ -0,0 +1,61 @@
1
+ import { GroupInfo } from './table.js';
2
+ export interface SettingRecord {
3
+ api?: string;
4
+ app_id?: string | null;
5
+ deprecated?: boolean | null;
6
+ fields?: Record<string, unknown>;
7
+ id?: string;
8
+ label?: string;
9
+ name?: string;
10
+ }
11
+ /**
12
+ * Settings identifier grammar (calibrated against `/data/:settings`).
13
+ *
14
+ * `app.<slug-or-hex>` → kind 'app' — slug must be translated to hex before
15
+ * a path GET; hex passes through.
16
+ * anything else valid → kind 'path' — direct path GET. The endpoint resolves
17
+ * bare system names (`taxes`), bare app
18
+ * hexes, and full dotted ids
19
+ * (`com.taxes`) on the path. No `name`
20
+ * query needed.
21
+ * anything else → kind 'invalid'
22
+ *
23
+ * The 3-segment `app.<slug>.<name>` form is rejected: settings collapse to one
24
+ * record per app at push time, so there is no sub-section identity the API can
25
+ * resolve back. Users wanting a single section run `--json | jq`.
26
+ */
27
+ export type ParsedSettingsKey = {
28
+ kind: 'app';
29
+ ref: string;
30
+ } | {
31
+ kind: 'path';
32
+ segment: string;
33
+ } | {
34
+ input: string;
35
+ kind: 'invalid';
36
+ };
37
+ export declare function parseSettingsKey(input: string): ParsedSettingsKey;
38
+ /**
39
+ * Produce the column-1 paste-back key for a settings record.
40
+ *
41
+ * System (no app_id): `record.name` (e.g. `taxes`).
42
+ * App with slug: `app.<slug>`.
43
+ * App without slug: `app.<hex>` (still pasteable — `app.<hex>` resolves).
44
+ *
45
+ * App records carry `name === app_id` (the hex), which is meaningless to a
46
+ * human; we never surface it. Ultimate fallback is `record.id` for degenerate
47
+ * records that have neither `app_id` nor `name`.
48
+ */
49
+ export declare function formatSettingsKey(record: SettingRecord, appSlugById: Record<string, string>): string;
50
+ /**
51
+ * Group a settings record. System rows (`app_id` null/undefined) belong to the
52
+ * platform and render under a `system` divider at the top, mirroring the
53
+ * notifications shape.
54
+ */
55
+ export declare function groupSettingsRecord(record: SettingRecord, appSlugById: Record<string, string>): GroupInfo;
56
+ /**
57
+ * `general` and `admin` are flagged `deprecated: true` in the base schema and
58
+ * still come back from the default list (no server-side filter equivalent —
59
+ * `deprecated[$ne]=true` doesn't take). Filter them out client-side.
60
+ */
61
+ export declare function isDeprecatedRecord(record: SettingRecord): boolean;
@@ -0,0 +1,56 @@
1
+ const PATH_SEGMENT = /^[\w.-]+$/;
2
+ const APP_PREFIX = /^app\.([^.]+)$/;
3
+ export function parseSettingsKey(input) {
4
+ const appMatch = input.match(APP_PREFIX);
5
+ if (appMatch) {
6
+ return { kind: 'app', ref: appMatch[1] };
7
+ }
8
+ if (input.startsWith('app.')) {
9
+ return { kind: 'invalid', input };
10
+ }
11
+ if (PATH_SEGMENT.test(input)) {
12
+ return { kind: 'path', segment: input };
13
+ }
14
+ return { kind: 'invalid', input };
15
+ }
16
+ /**
17
+ * Produce the column-1 paste-back key for a settings record.
18
+ *
19
+ * System (no app_id): `record.name` (e.g. `taxes`).
20
+ * App with slug: `app.<slug>`.
21
+ * App without slug: `app.<hex>` (still pasteable — `app.<hex>` resolves).
22
+ *
23
+ * App records carry `name === app_id` (the hex), which is meaningless to a
24
+ * human; we never surface it. Ultimate fallback is `record.id` for degenerate
25
+ * records that have neither `app_id` nor `name`.
26
+ */
27
+ export function formatSettingsKey(record, appSlugById) {
28
+ if (!record.app_id) {
29
+ return record.name ?? record.id ?? '-';
30
+ }
31
+ const slug = appSlugById[record.app_id] ?? record.app_id;
32
+ return `app.${slug}`;
33
+ }
34
+ /**
35
+ * Group a settings record. System rows (`app_id` null/undefined) belong to the
36
+ * platform and render under a `system` divider at the top, mirroring the
37
+ * notifications shape.
38
+ */
39
+ export function groupSettingsRecord(record, appSlugById) {
40
+ if (!record.app_id) {
41
+ return { slug: '<system>', label: 'system', order: 0 };
42
+ }
43
+ const resolved = appSlugById[record.app_id];
44
+ if (!resolved) {
45
+ return { slug: '<not resolved>', order: 2 };
46
+ }
47
+ return { slug: resolved };
48
+ }
49
+ /**
50
+ * `general` and `admin` are flagged `deprecated: true` in the base schema and
51
+ * still come back from the default list (no server-side filter equivalent —
52
+ * `deprecated[$ne]=true` doesn't take). Filter them out client-side.
53
+ */
54
+ export function isDeprecatedRecord(record) {
55
+ return record.deprecated === true;
56
+ }
@@ -0,0 +1,29 @@
1
+ export interface GroupInfo {
2
+ /** Grouping identity. Rows with the same slug land in the same section. */
3
+ slug: string;
4
+ /** Divider text. Defaults to `slug` when omitted. */
5
+ label?: string;
6
+ /** Sort priority; lower renders first. Ties preserve insertion order. Default 1. */
7
+ order?: number;
8
+ }
9
+ export interface KeyMetaRow {
10
+ /** Paste-back identifier rendered as column 1. */
11
+ key: string;
12
+ /** Optional compact status string. Empty or undefined renders the key alone. */
13
+ meta?: string;
14
+ /** Optional group; when some rows carry one, multiple groups produce dividers. */
15
+ group?: GroupInfo;
16
+ }
17
+ /**
18
+ * Render a two-column list: paste-back key + optional compact meta string.
19
+ *
20
+ * - Rows with meta render as ` <key-padded> <meta>`.
21
+ * - Rows without meta render as ` <key>` with no trailing whitespace.
22
+ * - Key column width is computed globally across all rows so meta columns
23
+ * align vertically.
24
+ * - Rows with a `group` are collected into sections; sections are separated
25
+ * by a blank line and a `── <label>` divider. Sections sort by `order`
26
+ * (lower first), ties preserve insertion order. A single group collapses
27
+ * to a flat listing.
28
+ */
29
+ export declare function renderKeyMetaTable(rows: KeyMetaRow[]): string[];
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Render a two-column list: paste-back key + optional compact meta string.
3
+ *
4
+ * - Rows with meta render as ` <key-padded> <meta>`.
5
+ * - Rows without meta render as ` <key>` with no trailing whitespace.
6
+ * - Key column width is computed globally across all rows so meta columns
7
+ * align vertically.
8
+ * - Rows with a `group` are collected into sections; sections are separated
9
+ * by a blank line and a `── <label>` divider. Sections sort by `order`
10
+ * (lower first), ties preserve insertion order. A single group collapses
11
+ * to a flat listing.
12
+ */
13
+ export function renderKeyMetaTable(rows) {
14
+ if (rows.length === 0) {
15
+ return [];
16
+ }
17
+ const keyWidth = Math.max(...rows.map((r) => r.key.length));
18
+ const formatRow = (row) => {
19
+ if (row.meta && row.meta.length > 0) {
20
+ return ` ${row.key.padEnd(keyWidth)} ${row.meta}`;
21
+ }
22
+ return ` ${row.key}`;
23
+ };
24
+ const hasGroups = rows.some((r) => r.group !== undefined);
25
+ if (!hasGroups) {
26
+ return rows.map((row) => formatRow(row));
27
+ }
28
+ const groupMap = new Map();
29
+ for (const row of rows) {
30
+ const info = row.group;
31
+ if (!info)
32
+ continue;
33
+ const existing = groupMap.get(info.slug);
34
+ if (existing) {
35
+ existing.rows.push(row);
36
+ }
37
+ else {
38
+ groupMap.set(info.slug, {
39
+ slug: info.slug,
40
+ label: info.label ?? info.slug,
41
+ order: info.order ?? 1,
42
+ rows: [row],
43
+ });
44
+ }
45
+ }
46
+ if (groupMap.size === 1) {
47
+ return rows.map((row) => formatRow(row));
48
+ }
49
+ const ordered = [...groupMap.values()].sort((a, b) => a.order - b.order);
50
+ const lines = [];
51
+ for (const group of ordered) {
52
+ if (lines.length > 0) {
53
+ lines.push('');
54
+ }
55
+ lines.push(`── ${group.label}`);
56
+ for (const row of group.rows) {
57
+ lines.push(formatRow(row));
58
+ }
59
+ }
60
+ return lines;
61
+ }
@@ -6,8 +6,9 @@ import * as path from 'node:path';
6
6
  import Stream from 'node:stream';
7
7
  import ora from 'ora';
8
8
  import { default as swellConfig } from './lib/app-config.js';
9
- import { ConfigType, getFrontendProjectValidValues, allConfigFilesInDir, CUSTOM_FRAMEWORK_SLUG, appConfigFromFile, filePathExists, filePathExistsAsync, findAppConfig, getAppSlugId, getConfigTypeFromPath, getConfigTypeKeyFromValue, getFrontendProjectType, getProjectCommands, globAllFilesByPath, hashString, isPathDirectory, } from './lib/apps/index.js';
9
+ import { ConfigType, getFrontendProjectValidValues, allConfigFilesInDir, CUSTOM_FRAMEWORK_SLUG, appConfigFromFile, filePathExists, filePathExistsAsync, findAppConfig, getConfigTypeFromPath, getConfigTypeKeyFromValue, getFrontendProjectType, getProjectCommands, globAllFilesByPath, hashString, isPathDirectory, } from './lib/apps/index.js';
10
10
  import { getGlobIgnorePathsChecker } from './lib/apps/paths.js';
11
+ import { slugFromApp } from './lib/apps/slug.js';
11
12
  import { default as localConfig } from './lib/config.js';
12
13
  import { toAppId } from './lib/create/index.js';
13
14
  import { detectPackageManager, transformCommand, } from './lib/package-manager.js';
@@ -139,7 +140,7 @@ export class PushAppCommand extends RemoteAppCommand {
139
140
  }
140
141
  const appSlugId = (await select({
141
142
  choices: apps.results.map((app) => ({
142
- name: `${style.appConfigValue(app.name)} (${getAppSlugId(app)})`,
143
+ name: `${style.appConfigValue(app.name)} (${slugFromApp(app) ?? app.id})`,
143
144
  value: app.private_id,
144
145
  })),
145
146
  message: `Choose ${typeLabelPrefixed} to pull`,
@@ -6,10 +6,6 @@ export declare abstract class SwellApiCommand extends SwellCommand {
6
6
  protected request(command: typeof SwellApiCommand, requestOptions?: Api.RequestOptions): Promise<void>;
7
7
  protected catch(error: Error): Promise<any>;
8
8
  private parseCommand;
9
- /**
10
- * Resolve app ObjectId from a friendly slug or return as-is if already an ObjectId.
11
- */
12
- private resolveAppId;
13
9
  /**
14
10
  * Resolve function ID from app ID and function name.
15
11
  */
@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { FetchError } from 'node-fetch';
5
5
  import { HttpMethod } from './lib/api.js';
6
+ import { resolveAppId } from './lib/apps/resolve.js';
6
7
  import { SwellCommand } from './swell-command.js';
7
8
  // Pattern to match /functions/{appId}/{functionName} with optional query string
8
9
  const FUNCTION_PATH_REGEX = /^\/functions\/([^/]+)\/([^/?]+)(\?.*)?$/;
@@ -85,28 +86,11 @@ export class SwellApiCommand extends SwellCommand {
85
86
  },
86
87
  };
87
88
  }
88
- /**
89
- * Resolve app ObjectId from a friendly slug or return as-is if already an ObjectId.
90
- */
91
- async resolveAppId(appIdOrSlug) {
92
- // If it looks like an ObjectId (24 hex chars), return as-is
93
- if (/^[\da-f]{24}$/i.test(appIdOrSlug)) {
94
- return appIdOrSlug;
95
- }
96
- // Fetch all installed apps and filter by public_id or private_id client-side
97
- const installedApps = await this.api.get({ adminPath: `/client/apps` });
98
- const app = installedApps?.results?.find((a) => a.app_public_id === appIdOrSlug ||
99
- a.app_private_id === `_${appIdOrSlug}`);
100
- if (!app) {
101
- throw new Error(`App '${appIdOrSlug}' not found`);
102
- }
103
- return app.app_id;
104
- }
105
89
  /**
106
90
  * Resolve function ID from app ID and function name.
107
91
  */
108
92
  async resolveFunctionId(appIdOrSlug, functionName) {
109
- const appId = await this.resolveAppId(appIdOrSlug);
93
+ const appId = await resolveAppId(this.api, appIdOrSlug);
110
94
  const functionRecord = await this.api.get({ adminPath: `/data/:functions` }, {
111
95
  query: {
112
96
  app_id: appId,