@bridgeline-digital/hawk-app-cli 0.1.0-dev.20261020202308 → 0.1.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 (80) hide show
  1. package/LICENSE +7 -7
  2. package/README.md +770 -770
  3. package/agents/AGENTS.md +192 -192
  4. package/agents/project/custom.md +21 -21
  5. package/agents/project/next-mui.md +21 -21
  6. package/agents/project/next.md +24 -24
  7. package/agents/project/vanilla.md +22 -22
  8. package/agents/project/vite.md +19 -19
  9. package/bin/hawk-app.mjs +0 -0
  10. package/docs/bridge/README.md +1315 -1315
  11. package/docs/showcase/README.md +47 -47
  12. package/docs/showcase/functions/echo.ts +12 -12
  13. package/docs/showcase/functions/long-task.ts +31 -31
  14. package/docs/showcase/functions/showcase.ts +186 -186
  15. package/docs/showcase/hawk-app.json +73 -73
  16. package/docs/showcase/queries/assistant-prompts-by-day.json +6 -6
  17. package/docs/showcase/queries/assistant-prompts-by-day.sql +8 -8
  18. package/docs/showcase/queries/searches-by-day.json +6 -6
  19. package/docs/showcase/queries/searches-by-day.sql +7 -7
  20. package/docs/showcase/src/App.tsx +1436 -1436
  21. package/docs/showcase/src/main.tsx +10 -10
  22. package/docs/showcase/src/styles.css +71 -71
  23. package/docs/showcase/src/useHawkApp.ts +44 -44
  24. package/docs/showcase/src/vite-env.d.ts +1 -1
  25. package/docs/ui/README.md +166 -166
  26. package/package.json +1 -1
  27. package/schema/hawk-app.schema.json +611 -611
  28. package/src/accessibility.mjs +105 -105
  29. package/src/agents.mjs +145 -145
  30. package/src/aliases.mjs +30 -30
  31. package/src/app-icon.mjs +82 -82
  32. package/src/channel.mjs +116 -116
  33. package/src/cli.mjs +825 -825
  34. package/src/clients.mjs +221 -221
  35. package/src/config.mjs +148 -148
  36. package/src/create.mjs +186 -186
  37. package/src/credentials.mjs +56 -56
  38. package/src/data.mjs +169 -169
  39. package/src/deploy.mjs +349 -349
  40. package/src/dev.mjs +459 -459
  41. package/src/doctor.mjs +176 -176
  42. package/src/env.mjs +140 -140
  43. package/src/functions.mjs +313 -313
  44. package/src/login.mjs +166 -166
  45. package/src/logs.mjs +64 -64
  46. package/src/manifest.mjs +425 -425
  47. package/src/mcp-docs.mjs +168 -168
  48. package/src/mcp-hawksearch.mjs +342 -342
  49. package/src/mcp-reports.mjs +130 -130
  50. package/src/mcp-rules.mjs +109 -109
  51. package/src/mcp.mjs +539 -539
  52. package/src/platform.mjs +116 -116
  53. package/src/project-checks.mjs +153 -153
  54. package/src/queries.mjs +289 -289
  55. package/src/scope-check.mjs +91 -91
  56. package/src/secrets.mjs +86 -86
  57. package/src/slug-guard.mjs +80 -80
  58. package/src/sso.mjs +182 -182
  59. package/src/telemetry.mjs +50 -50
  60. package/src/upgrade.mjs +406 -406
  61. package/src/versions.mjs +102 -102
  62. package/templates/next/next.config.ts +13 -13
  63. package/templates/next/package.json +26 -26
  64. package/templates/next/src/lib/useHawkApp.ts +46 -46
  65. package/templates/next-mui/next.config.ts +13 -13
  66. package/templates/next-mui/package.json +30 -30
  67. package/templates/next-mui/src/app/layout.tsx +19 -19
  68. package/templates/next-mui/src/app/page.tsx +78 -78
  69. package/templates/next-mui/src/app/providers.tsx +10 -10
  70. package/templates/preact/package.json +26 -26
  71. package/templates/preact/src/useHawkApp.ts +44 -44
  72. package/templates/preact/src/vite-env.d.ts +1 -1
  73. package/templates/preact/tsconfig.json +17 -17
  74. package/templates/preact/vite.config.ts +11 -11
  75. package/templates/react/package.json +29 -29
  76. package/templates/react/src/useHawkApp.ts +44 -44
  77. package/templates/react/src/vite-env.d.ts +1 -1
  78. package/templates/react/tsconfig.json +16 -16
  79. package/templates/react/vite.config.ts +11 -11
  80. package/templates/vanilla/package.json +15 -15
package/src/clients.mjs CHANGED
@@ -1,221 +1,221 @@
1
- import { existsSync } from 'node:fs';
2
- import { readFile, writeFile } from 'node:fs/promises';
3
- import { join } from 'node:path';
4
- import { createInterface } from 'node:readline';
5
- import { DEFAULT_ALIAS, checkAlias, describeClientAlias } from './aliases.mjs';
6
-
7
- /** Same rules as the platform: HawkSearch client ids, and the optional label kept with them. */
8
- export const CLIENT_ID = /^[A-Za-z0-9_.-]{1,100}$/;
9
- const MAX_CLIENT_NAME = 100;
10
-
11
- const USAGE = `Usage:
12
- hawk-app clients
13
- hawk-app clients search <name or id>
14
- hawk-app clients add <id> [<id> …] [--name "<name>"] [--alias <name>]
15
- hawk-app clients add --pick "<name or id>" [--alias <name>]
16
- hawk-app clients exclude <id> [--name "<name>"]
17
- hawk-app clients remove <id>`;
18
-
19
- const VISIBILITY = {
20
- allowList: 'allowList: only the clients listed with + have the app',
21
- all: 'all: every client has the app, except the ones listed with -',
22
- internal: 'internal: Bridgeline users only; the client list is ignored',
23
- };
24
-
25
- /**
26
- * `hawk-app clients …`: which clients have the app. The platform registry is the only source of truth (not
27
- * hawk-app.json); the same list is changed in the Hawk Analytics portal. Changes apply at once, without a deploy or
28
- * approval, and are recorded in the app's history. `client` is a factory, so a usage mistake shows before any login check.
29
- * `names: false` prints ids only (the MCP server: client names stay out of the assistant's context). `choose` asks the
30
- * person at the terminal to pick one of the matches for `add --pick`.
31
- */
32
- export async function clientsCommand({ client, manifest, sub = 'list', args = [], flags = {}, names = true, choose = chooseOnTerminal, log = console.log }) {
33
- if (sub === 'search') return searchClients({ client, text: args.join(' ').trim(), log });
34
- const slug = manifest.slug;
35
- const label = names ? labelOf : (c) => c.id;
36
- switch (sub) {
37
- case 'list': {
38
- const { visibility, clients = [] } = await call(() => client().listClients(slug), slug);
39
- log(`${slug}: visibility ${VISIBILITY[visibility] ?? visibility}`);
40
- if (!clients.length) {
41
- log(visibility === 'allowList' ? ' no client has it yet. Add one with `hawk-app clients add <id>`.' : ' (no clients listed)');
42
- return 0;
43
- }
44
- for (const c of clients) log(` ${c.enabled ? '+' : '-'} ${label(c)}${describeClientAlias(c)}`);
45
- return 0;
46
- }
47
-
48
- case 'add':
49
- case 'exclude': {
50
- const enabled = sub === 'add';
51
- if (flags.alias !== undefined && !enabled) throw new Error(`--alias is for \`clients add\`.\n${USAGE}`);
52
- const alias = flags.alias === undefined ? undefined : checkAlias(flags.alias, { allowDefault: true });
53
- const ids = enabled && flags.pick !== undefined ? [await pickClient({ client, text: flags.pick, slug, choose, log })] : checkIds(args);
54
- if (ids[0] === null) {
55
- log('Cancelled: no client was added.');
56
- return 1;
57
- }
58
- const name = checkName(flags.name);
59
- if (name !== undefined && ids.length > 1) throw new Error('--name labels one client: add the clients one at a time to name them.');
60
- // Read first: the warnings below need the registered visibility (it can differ from hawk-app.json until review).
61
- const { visibility } = await call(() => client().listClients(slug), slug);
62
- // --alias: the version they get. On an alias whose version isn't approved, only Bridgeline staff in them see it.
63
- let refused = 0;
64
- for (const id of ids) {
65
- try {
66
- const saved = await call(() => client().setClient(slug, id, { enabled, ...(alias ? { alias } : {}), ...(name !== undefined ? { name } : {}) }), slug);
67
- const where = saved.alias
68
- ? ` on ${saved.alias}${saved.aliasStatus && saved.aliasStatus !== 'approved' ? ` (${saved.aliasStatus}: only Bridgeline staff in it see the app until its version is approved)` : ''}`
69
- : '';
70
- log(`✔ ${enabled ? 'Added' : 'Excluded'} ${label(saved)} ${enabled ? 'to' : 'from'} ${slug}${where}. It applies now (recorded in the app's history).`);
71
- } catch (error) {
72
- if (error.data?.code !== 'approver_required') throw error;
73
- refused++;
74
- log(`✖ ${id}'s users see ${slug} now: only an approver can change what it gets (the app's Rollout tab in the Hawk Analytics portal, or HawkSearch).`);
75
- }
76
- }
77
- if (!enabled && visibility !== 'all') log(`! ${slug} has visibility "${visibility}": excluding a client only has an effect with visibility "all".`);
78
- if (enabled && visibility === 'internal') log(`! ${slug} has visibility "internal": only Bridgeline users see it until its visibility changes.`);
79
- if (alias && alias !== DEFAULT_ALIAS && refused < ids.length) log(` Back to the default version: \`hawk-app clients add <id> --alias ${DEFAULT_ALIAS}\`.`);
80
- return refused ? 1 : 0;
81
- }
82
-
83
- case 'remove': {
84
- const id = checkId(args[0]);
85
- try {
86
- await call(() => client().removeClient(slug, id), slug);
87
- } catch (error) {
88
- if (error.data?.code === 'client_not_found') throw new Error(`${id} is not in ${slug}'s client list; nothing was changed.`);
89
- throw error;
90
- }
91
- log(`✔ Removed ${id} from ${slug}'s client list. It applies now (recorded in the app's history).`);
92
- return 0;
93
- }
94
-
95
- default:
96
- throw new Error(`Unknown clients command "${sub}".\n${USAGE}`);
97
- }
98
- }
99
-
100
- /**
101
- * `hawk-app clients search <text>`: the environment's clients whose name or id matches (the Hawk Analytics portal's client
102
- * list), to find the id to add. Needs no app: it reads the list, nothing else.
103
- */
104
- async function searchClients({ client, text, log }) {
105
- if (text.length < 2) throw new Error(`Search with at least 2 characters of a client's name or id.\n${USAGE}`);
106
- const { clients = [], more } = await client().searchClients(text);
107
- if (!clients.length) {
108
- log(`No client matches "${text}". Try another part of its name, or the id the dashboard shows (e.g. e3141).`);
109
- return 0;
110
- }
111
- log(`Clients matching "${text}"${more ? ' (the best matches; search for more of the name to narrow it down)' : ''}:`);
112
- for (const c of clients) log(` ${labelOf(c)}`);
113
- log('Add one with `hawk-app clients add <id>`.');
114
- return 0;
115
- }
116
-
117
- /**
118
- * `clients add --pick "<text>"`: finds the matching clients and has the person at the terminal pick one. The id, or
119
- * null when they cancel. This is where the MCP server sends people whose assistant can't show a picker, so the names are
120
- * only ever on their screen.
121
- */
122
- async function pickClient({ client, text, slug, choose, log }) {
123
- const wanted = typeof text === 'string' ? text.trim() : '';
124
- if (wanted.length < 2) throw new Error(`--pick needs at least 2 characters of a client's name or id, e.g. --pick "outdoor".\n${USAGE}`);
125
- const { clients = [], more } = await client().searchClients(wanted);
126
- if (!clients.length) throw new Error(`No client matches "${wanted}". Try another part of its name, or the id the dashboard shows (e.g. e3141).`);
127
- const picked = await choose(`Which client should get ${slug}?${more ? ' (the best matches; cancel and use more of the name if yours is missing)' : ''}`, clients.map(labelOf));
128
- if (picked === undefined) throw new Error('Picking a client needs a terminal. Run `hawk-app clients search` and then `hawk-app clients add <id>` instead.');
129
- return picked === null ? null : clients[picked].id;
130
- }
131
-
132
- /**
133
- * `hawk-app dev` without a client: asks for part of a client's name, then which of the matches. The id; throws when
134
- * there's no terminal or nothing is picked.
135
- */
136
- export async function pickDevClient({ client, slug, ask = askOnTerminal, choose = chooseOnTerminal, log = console.log }) {
137
- const noTerminal = 'Which client? Run `hawk-app dev --client <id>` (find the id with `hawk-app clients search <name>`).';
138
- for (;;) {
139
- const text = await ask(`Run ${slug} with which client's data? Part of its name or id: `);
140
- if (text === undefined) throw new Error(noTerminal);
141
- if (!text.trim()) throw new Error('No client picked.');
142
- if (text.trim().length < 2) {
143
- log(' At least 2 characters, please.');
144
- continue;
145
- }
146
- const { clients = [], more } = await client().searchClients(text.trim());
147
- if (!clients.length) {
148
- log(` No client matches "${text.trim()}". Try another part of its name, or its id (e.g. e3141).`);
149
- continue;
150
- }
151
- const picked = await choose(`Which client?${more ? ' (the best matches; Enter and use more of the name if yours is missing)' : ''}`, clients.map(labelOf));
152
- if (picked === undefined) throw new Error(noTerminal);
153
- if (picked !== null) return clients[picked].id;
154
- }
155
- }
156
-
157
- /** Saves the dev client in the project's .env.local (git-ignored), replacing an earlier one. */
158
- export async function rememberDevClient(projectDir, id) {
159
- const file = join(projectDir, '.env.local');
160
- const text = existsSync(file) ? await readFile(file, 'utf8') : '';
161
- const kept = text.split(/\r?\n/).filter((line) => line && !/^\s*(export\s+)?HAWK_APPS_DEV_CLIENT\s*=/.test(line));
162
- await writeFile(file, [...kept, `HAWK_APPS_DEV_CLIENT=${id}`, ''].join('\n'));
163
- }
164
-
165
- /** A line of text from the terminal; undefined when there's no terminal. */
166
- export function askOnTerminal(question, { input = process.stdin, output = process.stdout } = {}) {
167
- if (!input.isTTY) return Promise.resolve(undefined);
168
- return new Promise((resolve) => {
169
- const rl = createInterface({ input, output });
170
- rl.question(question, (answer) => {
171
- rl.close();
172
- resolve(answer);
173
- });
174
- });
175
- }
176
-
177
- /** Numbered choices on the terminal: the index picked, null for none (Enter), undefined when there's no terminal. */
178
- export function chooseOnTerminal(question, options, { input = process.stdin, output = process.stdout } = {}) {
179
- if (!input.isTTY) return Promise.resolve(undefined);
180
- return new Promise((resolve) => {
181
- const rl = createInterface({ input, output });
182
- output.write(`${question}\n${options.map((o, i) => ` ${i + 1}. ${o}`).join('\n')}\n`);
183
- rl.question(`Number (Enter to cancel): `, (answer) => {
184
- rl.close();
185
- const n = Number(answer.trim());
186
- resolve(Number.isInteger(n) && n >= 1 && n <= options.length ? n - 1 : null);
187
- });
188
- });
189
- }
190
-
191
- /** Platform calls, saying how to add the app when it isn't on the platform yet. */
192
- async function call(fn, slug) {
193
- try {
194
- return await fn();
195
- } catch (error) {
196
- if (error.data?.code === 'app_not_found') throw new Error(`${slug} isn't on the platform yet: \`npm run app:dev\` or \`npm run app:deploy\` adds it.`);
197
- throw error;
198
- }
199
- }
200
-
201
- const labelOf = (c) => `${c.id}${c.name ? ` (${c.name})` : ''}`;
202
-
203
-
204
-
205
- function checkIds(ids) {
206
- if (!ids.length) throw new Error(USAGE);
207
- return [...new Set(ids.map(checkId))];
208
- }
209
-
210
- function checkId(id) {
211
- if (!id) throw new Error(USAGE);
212
- if (!CLIENT_ID.test(id)) throw new Error(`"${id}" is not a valid client id: 1-100 letters, digits, "_", "." or "-" (e.g. e3141).`);
213
- return id;
214
- }
215
-
216
- function checkName(name) {
217
- if (name === undefined) return undefined;
218
- if (typeof name !== 'string') throw new Error('--name needs a value, e.g. --name "Outdoor Demo".');
219
- if (name.length > MAX_CLIENT_NAME) throw new Error(`--name is longer than ${MAX_CLIENT_NAME} characters.`);
220
- return name;
221
- }
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { createInterface } from 'node:readline';
5
+ import { DEFAULT_ALIAS, checkAlias, describeClientAlias } from './aliases.mjs';
6
+
7
+ /** Same rules as the platform: HawkSearch client ids, and the optional label kept with them. */
8
+ export const CLIENT_ID = /^[A-Za-z0-9_.-]{1,100}$/;
9
+ const MAX_CLIENT_NAME = 100;
10
+
11
+ const USAGE = `Usage:
12
+ hawk-app clients
13
+ hawk-app clients search <name or id>
14
+ hawk-app clients add <id> [<id> …] [--name "<name>"] [--alias <name>]
15
+ hawk-app clients add --pick "<name or id>" [--alias <name>]
16
+ hawk-app clients exclude <id> [--name "<name>"]
17
+ hawk-app clients remove <id>`;
18
+
19
+ const VISIBILITY = {
20
+ allowList: 'allowList: only the clients listed with + have the app',
21
+ all: 'all: every client has the app, except the ones listed with -',
22
+ internal: 'internal: Bridgeline users only; the client list is ignored',
23
+ };
24
+
25
+ /**
26
+ * `hawk-app clients …`: which clients have the app. The platform registry is the only source of truth (not
27
+ * hawk-app.json); the same list is changed in the Hawk Analytics portal. Changes apply at once, without a deploy or
28
+ * approval, and are recorded in the app's history. `client` is a factory, so a usage mistake shows before any login check.
29
+ * `names: false` prints ids only (the MCP server: client names stay out of the assistant's context). `choose` asks the
30
+ * person at the terminal to pick one of the matches for `add --pick`.
31
+ */
32
+ export async function clientsCommand({ client, manifest, sub = 'list', args = [], flags = {}, names = true, choose = chooseOnTerminal, log = console.log }) {
33
+ if (sub === 'search') return searchClients({ client, text: args.join(' ').trim(), log });
34
+ const slug = manifest.slug;
35
+ const label = names ? labelOf : (c) => c.id;
36
+ switch (sub) {
37
+ case 'list': {
38
+ const { visibility, clients = [] } = await call(() => client().listClients(slug), slug);
39
+ log(`${slug}: visibility ${VISIBILITY[visibility] ?? visibility}`);
40
+ if (!clients.length) {
41
+ log(visibility === 'allowList' ? ' no client has it yet. Add one with `hawk-app clients add <id>`.' : ' (no clients listed)');
42
+ return 0;
43
+ }
44
+ for (const c of clients) log(` ${c.enabled ? '+' : '-'} ${label(c)}${describeClientAlias(c)}`);
45
+ return 0;
46
+ }
47
+
48
+ case 'add':
49
+ case 'exclude': {
50
+ const enabled = sub === 'add';
51
+ if (flags.alias !== undefined && !enabled) throw new Error(`--alias is for \`clients add\`.\n${USAGE}`);
52
+ const alias = flags.alias === undefined ? undefined : checkAlias(flags.alias, { allowDefault: true });
53
+ const ids = enabled && flags.pick !== undefined ? [await pickClient({ client, text: flags.pick, slug, choose, log })] : checkIds(args);
54
+ if (ids[0] === null) {
55
+ log('Cancelled: no client was added.');
56
+ return 1;
57
+ }
58
+ const name = checkName(flags.name);
59
+ if (name !== undefined && ids.length > 1) throw new Error('--name labels one client: add the clients one at a time to name them.');
60
+ // Read first: the warnings below need the registered visibility (it can differ from hawk-app.json until review).
61
+ const { visibility } = await call(() => client().listClients(slug), slug);
62
+ // --alias: the version they get. On an alias whose version isn't approved, only Bridgeline staff in them see it.
63
+ let refused = 0;
64
+ for (const id of ids) {
65
+ try {
66
+ const saved = await call(() => client().setClient(slug, id, { enabled, ...(alias ? { alias } : {}), ...(name !== undefined ? { name } : {}) }), slug);
67
+ const where = saved.alias
68
+ ? ` on ${saved.alias}${saved.aliasStatus && saved.aliasStatus !== 'approved' ? ` (${saved.aliasStatus}: only Bridgeline staff in it see the app until its version is approved)` : ''}`
69
+ : '';
70
+ log(`✔ ${enabled ? 'Added' : 'Excluded'} ${label(saved)} ${enabled ? 'to' : 'from'} ${slug}${where}. It applies now (recorded in the app's history).`);
71
+ } catch (error) {
72
+ if (error.data?.code !== 'approver_required') throw error;
73
+ refused++;
74
+ log(`✖ ${id}'s users see ${slug} now: only an approver can change what it gets (the app's Rollout tab in the Hawk Analytics portal, or HawkSearch).`);
75
+ }
76
+ }
77
+ if (!enabled && visibility !== 'all') log(`! ${slug} has visibility "${visibility}": excluding a client only has an effect with visibility "all".`);
78
+ if (enabled && visibility === 'internal') log(`! ${slug} has visibility "internal": only Bridgeline users see it until its visibility changes.`);
79
+ if (alias && alias !== DEFAULT_ALIAS && refused < ids.length) log(` Back to the default version: \`hawk-app clients add <id> --alias ${DEFAULT_ALIAS}\`.`);
80
+ return refused ? 1 : 0;
81
+ }
82
+
83
+ case 'remove': {
84
+ const id = checkId(args[0]);
85
+ try {
86
+ await call(() => client().removeClient(slug, id), slug);
87
+ } catch (error) {
88
+ if (error.data?.code === 'client_not_found') throw new Error(`${id} is not in ${slug}'s client list; nothing was changed.`);
89
+ throw error;
90
+ }
91
+ log(`✔ Removed ${id} from ${slug}'s client list. It applies now (recorded in the app's history).`);
92
+ return 0;
93
+ }
94
+
95
+ default:
96
+ throw new Error(`Unknown clients command "${sub}".\n${USAGE}`);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * `hawk-app clients search <text>`: the environment's clients whose name or id matches (the Hawk Analytics portal's client
102
+ * list), to find the id to add. Needs no app: it reads the list, nothing else.
103
+ */
104
+ async function searchClients({ client, text, log }) {
105
+ if (text.length < 2) throw new Error(`Search with at least 2 characters of a client's name or id.\n${USAGE}`);
106
+ const { clients = [], more } = await client().searchClients(text);
107
+ if (!clients.length) {
108
+ log(`No client matches "${text}". Try another part of its name, or the id the dashboard shows (e.g. e3141).`);
109
+ return 0;
110
+ }
111
+ log(`Clients matching "${text}"${more ? ' (the best matches; search for more of the name to narrow it down)' : ''}:`);
112
+ for (const c of clients) log(` ${labelOf(c)}`);
113
+ log('Add one with `hawk-app clients add <id>`.');
114
+ return 0;
115
+ }
116
+
117
+ /**
118
+ * `clients add --pick "<text>"`: finds the matching clients and has the person at the terminal pick one. The id, or
119
+ * null when they cancel. This is where the MCP server sends people whose assistant can't show a picker, so the names are
120
+ * only ever on their screen.
121
+ */
122
+ async function pickClient({ client, text, slug, choose, log }) {
123
+ const wanted = typeof text === 'string' ? text.trim() : '';
124
+ if (wanted.length < 2) throw new Error(`--pick needs at least 2 characters of a client's name or id, e.g. --pick "outdoor".\n${USAGE}`);
125
+ const { clients = [], more } = await client().searchClients(wanted);
126
+ if (!clients.length) throw new Error(`No client matches "${wanted}". Try another part of its name, or the id the dashboard shows (e.g. e3141).`);
127
+ const picked = await choose(`Which client should get ${slug}?${more ? ' (the best matches; cancel and use more of the name if yours is missing)' : ''}`, clients.map(labelOf));
128
+ if (picked === undefined) throw new Error('Picking a client needs a terminal. Run `hawk-app clients search` and then `hawk-app clients add <id>` instead.');
129
+ return picked === null ? null : clients[picked].id;
130
+ }
131
+
132
+ /**
133
+ * `hawk-app dev` without a client: asks for part of a client's name, then which of the matches. The id; throws when
134
+ * there's no terminal or nothing is picked.
135
+ */
136
+ export async function pickDevClient({ client, slug, ask = askOnTerminal, choose = chooseOnTerminal, log = console.log }) {
137
+ const noTerminal = 'Which client? Run `hawk-app dev --client <id>` (find the id with `hawk-app clients search <name>`).';
138
+ for (;;) {
139
+ const text = await ask(`Run ${slug} with which client's data? Part of its name or id: `);
140
+ if (text === undefined) throw new Error(noTerminal);
141
+ if (!text.trim()) throw new Error('No client picked.');
142
+ if (text.trim().length < 2) {
143
+ log(' At least 2 characters, please.');
144
+ continue;
145
+ }
146
+ const { clients = [], more } = await client().searchClients(text.trim());
147
+ if (!clients.length) {
148
+ log(` No client matches "${text.trim()}". Try another part of its name, or its id (e.g. e3141).`);
149
+ continue;
150
+ }
151
+ const picked = await choose(`Which client?${more ? ' (the best matches; Enter and use more of the name if yours is missing)' : ''}`, clients.map(labelOf));
152
+ if (picked === undefined) throw new Error(noTerminal);
153
+ if (picked !== null) return clients[picked].id;
154
+ }
155
+ }
156
+
157
+ /** Saves the dev client in the project's .env.local (git-ignored), replacing an earlier one. */
158
+ export async function rememberDevClient(projectDir, id) {
159
+ const file = join(projectDir, '.env.local');
160
+ const text = existsSync(file) ? await readFile(file, 'utf8') : '';
161
+ const kept = text.split(/\r?\n/).filter((line) => line && !/^\s*(export\s+)?HAWK_APPS_DEV_CLIENT\s*=/.test(line));
162
+ await writeFile(file, [...kept, `HAWK_APPS_DEV_CLIENT=${id}`, ''].join('\n'));
163
+ }
164
+
165
+ /** A line of text from the terminal; undefined when there's no terminal. */
166
+ export function askOnTerminal(question, { input = process.stdin, output = process.stdout } = {}) {
167
+ if (!input.isTTY) return Promise.resolve(undefined);
168
+ return new Promise((resolve) => {
169
+ const rl = createInterface({ input, output });
170
+ rl.question(question, (answer) => {
171
+ rl.close();
172
+ resolve(answer);
173
+ });
174
+ });
175
+ }
176
+
177
+ /** Numbered choices on the terminal: the index picked, null for none (Enter), undefined when there's no terminal. */
178
+ export function chooseOnTerminal(question, options, { input = process.stdin, output = process.stdout } = {}) {
179
+ if (!input.isTTY) return Promise.resolve(undefined);
180
+ return new Promise((resolve) => {
181
+ const rl = createInterface({ input, output });
182
+ output.write(`${question}\n${options.map((o, i) => ` ${i + 1}. ${o}`).join('\n')}\n`);
183
+ rl.question(`Number (Enter to cancel): `, (answer) => {
184
+ rl.close();
185
+ const n = Number(answer.trim());
186
+ resolve(Number.isInteger(n) && n >= 1 && n <= options.length ? n - 1 : null);
187
+ });
188
+ });
189
+ }
190
+
191
+ /** Platform calls, saying how to add the app when it isn't on the platform yet. */
192
+ async function call(fn, slug) {
193
+ try {
194
+ return await fn();
195
+ } catch (error) {
196
+ if (error.data?.code === 'app_not_found') throw new Error(`${slug} isn't on the platform yet: \`npm run app:dev\` or \`npm run app:deploy\` adds it.`);
197
+ throw error;
198
+ }
199
+ }
200
+
201
+ const labelOf = (c) => `${c.id}${c.name ? ` (${c.name})` : ''}`;
202
+
203
+
204
+
205
+ function checkIds(ids) {
206
+ if (!ids.length) throw new Error(USAGE);
207
+ return [...new Set(ids.map(checkId))];
208
+ }
209
+
210
+ function checkId(id) {
211
+ if (!id) throw new Error(USAGE);
212
+ if (!CLIENT_ID.test(id)) throw new Error(`"${id}" is not a valid client id: 1-100 letters, digits, "_", "." or "-" (e.g. e3141).`);
213
+ return id;
214
+ }
215
+
216
+ function checkName(name) {
217
+ if (name === undefined) return undefined;
218
+ if (typeof name !== 'string') throw new Error('--name needs a value, e.g. --name "Outdoor Demo".');
219
+ if (name.length > MAX_CLIENT_NAME) throw new Error(`--name is longer than ${MAX_CLIENT_NAME} characters.`);
220
+ return name;
221
+ }