@swell/cli 2.6.0 → 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.
- package/dist/commands/app/frontend/dev.js +18 -0
- package/dist/commands/inspect/content.d.ts +25 -14
- package/dist/commands/inspect/content.js +34 -144
- package/dist/commands/inspect/functions.d.ts +48 -0
- package/dist/commands/inspect/functions.js +83 -0
- package/dist/commands/inspect/index.js +8 -7
- package/dist/commands/inspect/models.d.ts +17 -2
- package/dist/commands/inspect/models.js +114 -47
- package/dist/commands/inspect/notifications.d.ts +31 -0
- package/dist/commands/inspect/notifications.js +125 -0
- package/dist/commands/inspect/settings.d.ts +28 -0
- package/dist/commands/inspect/settings.js +82 -0
- package/dist/commands/inspect/webhooks.d.ts +34 -0
- package/dist/commands/inspect/webhooks.js +61 -0
- package/dist/create-app-command.d.ts +7 -0
- package/dist/create-app-command.js +66 -3
- package/dist/inspect-resource-command.d.ts +91 -0
- package/dist/inspect-resource-command.js +232 -0
- package/dist/lib/apps/index.d.ts +2 -1
- package/dist/lib/apps/index.js +43 -6
- package/dist/lib/apps/inspect-scope.d.ts +58 -0
- package/dist/lib/apps/inspect-scope.js +60 -0
- package/dist/lib/apps/object-id.d.ts +7 -0
- package/dist/lib/apps/object-id.js +9 -0
- package/dist/lib/apps/paths.js +8 -1
- package/dist/lib/apps/resolve.d.ts +16 -0
- package/dist/lib/apps/resolve.js +39 -0
- package/dist/lib/apps/slug.d.ts +29 -0
- package/dist/lib/apps/slug.js +12 -0
- package/dist/lib/inspect/content.d.ts +39 -0
- package/dist/lib/inspect/content.js +76 -0
- package/dist/lib/inspect/notifications.d.ts +115 -0
- package/dist/lib/inspect/notifications.js +173 -0
- package/dist/lib/inspect/settings.d.ts +61 -0
- package/dist/lib/inspect/settings.js +56 -0
- package/dist/lib/inspect/table.d.ts +29 -0
- package/dist/lib/inspect/table.js +61 -0
- package/dist/push-app-command.js +3 -2
- package/dist/swell-api-command.d.ts +0 -4
- package/dist/swell-api-command.js +2 -18
- package/oclif.manifest.json +328 -35
- 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
|
+
}
|
package/dist/lib/apps/paths.js
CHANGED
|
@@ -9,13 +9,20 @@ function globOptions(appPath, options) {
|
|
|
9
9
|
gitignore: true,
|
|
10
10
|
...options,
|
|
11
11
|
expandDirectories: true,
|
|
12
|
-
//
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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;
|