@bridgeline-digital/hawk-app-cli 0.0.0-stage → 0.1.0-dev.20261020202308
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/LICENSE +7 -0
- package/README.md +770 -3
- package/agents/AGENTS.md +192 -0
- package/agents/project/custom.md +21 -0
- package/agents/project/next-mui.md +21 -0
- package/agents/project/next.md +24 -0
- package/agents/project/vanilla.md +22 -0
- package/agents/project/vite.md +19 -0
- package/bin/hawk-app.mjs +10 -0
- package/docs/bridge/README.md +1315 -0
- package/docs/showcase/README.md +47 -0
- package/docs/showcase/functions/echo.ts +12 -0
- package/docs/showcase/functions/long-task.ts +31 -0
- package/docs/showcase/functions/showcase.ts +186 -0
- package/docs/showcase/hawk-app.json +73 -0
- package/docs/showcase/queries/assistant-prompts-by-day.json +6 -0
- package/docs/showcase/queries/assistant-prompts-by-day.sql +8 -0
- package/docs/showcase/queries/searches-by-day.json +6 -0
- package/docs/showcase/queries/searches-by-day.sql +7 -0
- package/docs/showcase/src/App.tsx +1436 -0
- package/docs/showcase/src/main.tsx +10 -0
- package/docs/showcase/src/styles.css +71 -0
- package/docs/showcase/src/useHawkApp.ts +44 -0
- package/docs/showcase/src/vite-env.d.ts +1 -0
- package/docs/ui/README.md +166 -0
- package/package.json +50 -4
- package/schema/hawk-app.schema.json +611 -0
- package/src/accessibility.mjs +105 -0
- package/src/agents.mjs +145 -0
- package/src/aliases.mjs +30 -0
- package/src/app-icon.mjs +82 -0
- package/src/channel.mjs +116 -0
- package/src/cli.mjs +825 -0
- package/src/clients.mjs +221 -0
- package/src/config.mjs +148 -0
- package/src/create.mjs +186 -0
- package/src/credentials.mjs +56 -0
- package/src/data.mjs +169 -0
- package/src/deploy.mjs +349 -0
- package/src/dev.mjs +459 -0
- package/src/doctor.mjs +176 -0
- package/src/env.mjs +140 -0
- package/src/functions.mjs +313 -0
- package/src/login.mjs +166 -0
- package/src/logs.mjs +64 -0
- package/src/manifest.mjs +425 -0
- package/src/mcp-docs.mjs +168 -0
- package/src/mcp-hawksearch.mjs +342 -0
- package/src/mcp-reports.mjs +130 -0
- package/src/mcp-rules.mjs +109 -0
- package/src/mcp-ui.mjs +73 -0
- package/src/mcp.mjs +539 -0
- package/src/platform.mjs +116 -0
- package/src/project-checks.mjs +153 -0
- package/src/queries.mjs +289 -0
- package/src/scope-check.mjs +91 -0
- package/src/secrets.mjs +86 -0
- package/src/slug-guard.mjs +80 -0
- package/src/sso.mjs +182 -0
- package/src/store-listing.mjs +106 -0
- package/src/telemetry.mjs +50 -0
- package/src/template-history.json +34 -0
- package/src/upgrade.mjs +406 -0
- package/src/versions.mjs +102 -0
- package/templates/next/next.config.ts +13 -0
- package/templates/next/package.json +26 -0
- package/templates/next/src/app/globals.css +5 -0
- package/templates/next/src/app/layout.tsx +14 -0
- package/templates/next/src/app/page.tsx +86 -0
- package/templates/next/src/hawk-elements.d.ts +10 -0
- package/templates/next/src/lib/useHawkApp.ts +46 -0
- package/templates/next/tsconfig.json +21 -0
- package/templates/next-mui/next.config.ts +13 -0
- package/templates/next-mui/package.json +31 -0
- package/templates/next-mui/src/app/layout.tsx +20 -0
- package/templates/next-mui/src/app/page.tsx +79 -0
- package/templates/next-mui/src/app/providers.tsx +11 -0
- package/templates/next-mui/tsconfig.json +21 -0
- package/templates/preact/index.html +12 -0
- package/templates/preact/package.json +26 -0
- package/templates/preact/src/App.tsx +83 -0
- package/templates/preact/src/hawk-elements.d.ts +10 -0
- package/templates/preact/src/main.tsx +8 -0
- package/templates/preact/src/styles.css +5 -0
- package/templates/preact/src/useHawkApp.ts +44 -0
- package/templates/preact/src/vite-env.d.ts +1 -0
- package/templates/preact/tsconfig.json +17 -0
- package/templates/preact/vite.config.ts +11 -0
- package/templates/react/index.html +12 -0
- package/templates/react/package.json +29 -0
- package/templates/react/src/App.tsx +83 -0
- package/templates/react/src/hawk-elements.d.ts +10 -0
- package/templates/react/src/main.tsx +13 -0
- package/templates/react/src/styles.css +5 -0
- package/templates/react/src/useHawkApp.ts +44 -0
- package/templates/react/src/vite-env.d.ts +1 -0
- package/templates/react/tsconfig.json +16 -0
- package/templates/react/vite.config.ts +11 -0
- package/templates/vanilla/package.json +15 -0
- package/templates/vanilla/public/app.js +59 -0
- package/templates/vanilla/public/index.html +37 -0
- package/templates/vanilla/public/styles.css +5 -0
package/src/clients.mjs
ADDED
|
@@ -0,0 +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
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { CLIENT_ID } from './clients.mjs';
|
|
2
|
+
import { CONFIG_KEY, configValueError } from './manifest.mjs';
|
|
3
|
+
|
|
4
|
+
const USAGE = `Usage:
|
|
5
|
+
hawk-app config list [--client <id>]
|
|
6
|
+
hawk-app config set <key> <value> [--client <id>]
|
|
7
|
+
hawk-app config unset <key> [--client <id>]`;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `hawk-app config …`: the app's config values (declared in "config" in hawk-app.json), set for the whole app (global)
|
|
11
|
+
* or one client (`--client`). Values apply within about 30 seconds, without a deploy or approval, and are not secret:
|
|
12
|
+
* they reach the app's browser code. `client` is a factory, so a usage mistake shows before any login check.
|
|
13
|
+
*/
|
|
14
|
+
export async function configCommand({ client, manifest, sub, args = [], flags = {}, log = console.log }) {
|
|
15
|
+
const slug = manifest.slug;
|
|
16
|
+
// A bare --client (no id after it) is a mistake, never the global value.
|
|
17
|
+
const clientId = Object.hasOwn(flags, 'client') ? checkClient(flags.client) : null;
|
|
18
|
+
const layer = clientId ? `client ${clientId}` : 'global';
|
|
19
|
+
switch (sub) {
|
|
20
|
+
case 'list': {
|
|
21
|
+
const { keys = [] } = await call(() => client().getConfig(slug), slug);
|
|
22
|
+
const localKeys = declared(manifest);
|
|
23
|
+
if (!keys.length) {
|
|
24
|
+
log(`${slug} has no config keys on the platform. Declare them in "config" in hawk-app.json and deploy (or \`hawk-app register\`).`);
|
|
25
|
+
} else {
|
|
26
|
+
log(`${keys.length} config key${keys.length === 1 ? '' : 's'} for ${slug}${clientId ? `, client ${clientId}` : ''} (values are not secret: they reach the browser):`);
|
|
27
|
+
for (const k of keys) {
|
|
28
|
+
const head = k.declared === false ? `(not declared: its values are ignored; hawk-app config unset ${k.key} deletes them)` :`${k.type} ${k.default === null || k.default === undefined ? 'no default' : `default ${formatValue(k.default)}`}`;
|
|
29
|
+
log(` ${k.key} ${head}`);
|
|
30
|
+
const rows = [['global', k.global], ...(k.clients ?? []).filter((c) => !clientId || c.clientId === clientId).map((c) => [c.clientId, c])];
|
|
31
|
+
if (clientId && !rows.some(([id]) => id === clientId)) rows.push([clientId, null]);
|
|
32
|
+
const width = Math.max(...rows.map(([id]) => id.length));
|
|
33
|
+
for (const [id, stored] of rows) log(` ${id.padEnd(width)} ${describeStored(stored)}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const missing = [...localKeys.keys()].filter((key) => !keys.some((k) => k.key === key));
|
|
37
|
+
for (const key of missing) log(`! ${key} is in hawk-app.json but not on the platform yet: deploy (or \`hawk-app register\`) to declare it.`);
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
case 'set': {
|
|
42
|
+
const [key, raw] = args;
|
|
43
|
+
if (!key || raw === undefined) throw new Error(USAGE);
|
|
44
|
+
const declaration = local(manifest, key);
|
|
45
|
+
const value = parseValue(key, declaration.type, raw);
|
|
46
|
+
await write(client, slug, key, value, clientId);
|
|
47
|
+
log(`✔ Set ${key} for ${slug} (${layer}): ${formatValue(value)}. It applies within about 30 seconds, without a deploy.`);
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
case 'unset': {
|
|
52
|
+
const [key] = args;
|
|
53
|
+
if (!key) throw new Error(USAGE);
|
|
54
|
+
if (!CONFIG_KEY.test(key)) throw new Error(`"${key}" is not a valid config key: a letter, then letters, digits and "_" (at most 64 characters).`);
|
|
55
|
+
await write(client, slug, key, null, clientId);
|
|
56
|
+
log(
|
|
57
|
+
`✔ Cleared ${key} for ${slug} (${layer}). ${clientId ? `${clientId} now gets the global value, else the default.` : "Clients without their own value now get the default."}`,
|
|
58
|
+
);
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
default:
|
|
63
|
+
throw new Error(sub ? `Unknown config command "${sub}".\n${USAGE}` : USAGE);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function checkClient(id) {
|
|
68
|
+
if (typeof id !== 'string' || !id) throw new Error(`--client needs a client id, e.g. --client e3141.\n${USAGE}`);
|
|
69
|
+
if (!CLIENT_ID.test(id)) throw new Error(`"${id}" is not a valid client id: 1-100 letters, digits, "_", "." or "-" (e.g. e3141).`);
|
|
70
|
+
return id;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Key → declaration, from the local hawk-app.json. */
|
|
74
|
+
function declared(manifest) {
|
|
75
|
+
const config = manifest.config && typeof manifest.config === 'object' && !Array.isArray(manifest.config) ? manifest.config : {};
|
|
76
|
+
return new Map(Object.entries(config).filter(([, d]) => d && typeof d === 'object'));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function local(manifest, key) {
|
|
80
|
+
const declaration = declared(manifest).get(key);
|
|
81
|
+
if (!declaration) throw new Error(`${key} isn't declared in hawk-app.json: add it to "config" with its type (e.g. "${key}": { "type": "string" }), then deploy.`);
|
|
82
|
+
return declaration;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The command-line text as a value of the key's declared type, checked like the platform checks it. */
|
|
86
|
+
export function parseValue(key, type, raw) {
|
|
87
|
+
let value;
|
|
88
|
+
switch (type) {
|
|
89
|
+
case 'string':
|
|
90
|
+
value = raw;
|
|
91
|
+
break;
|
|
92
|
+
case 'number':
|
|
93
|
+
value = raw.trim() === '' ? NaN : Number(raw);
|
|
94
|
+
if (!Number.isFinite(value)) throw new Error(`${key} is a number: "${raw}" isn't one.`);
|
|
95
|
+
break;
|
|
96
|
+
case 'boolean':
|
|
97
|
+
if (raw !== 'true' && raw !== 'false') throw new Error(`${key} is a boolean: use true or false, not "${raw}".`);
|
|
98
|
+
value = raw === 'true';
|
|
99
|
+
break;
|
|
100
|
+
case 'emails':
|
|
101
|
+
value = raw.split(',').map((a) => a.trim()).filter(Boolean);
|
|
102
|
+
break;
|
|
103
|
+
case 'json':
|
|
104
|
+
try {
|
|
105
|
+
value = JSON.parse(raw);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
throw new Error(`${key} is JSON, but "${raw}" isn't valid JSON (${error.message}). Quote it for your shell, e.g. '{"a":1}'.`);
|
|
108
|
+
}
|
|
109
|
+
break;
|
|
110
|
+
default:
|
|
111
|
+
throw new Error(`${key} has no valid type in hawk-app.json ("${type}"): run \`hawk-app validate\`.`);
|
|
112
|
+
}
|
|
113
|
+
const problem = configValueError(type, value, key);
|
|
114
|
+
if (problem) throw new Error(problem);
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function write(client, slug, key, value, clientId) {
|
|
119
|
+
try {
|
|
120
|
+
return await call(() => client().setConfig(slug, key, value, clientId), slug);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error.data?.code === 'config_key_not_declared') {
|
|
123
|
+
throw new Error(`${key} isn't declared on the platform: deploy (or \`hawk-app register\`) hawk-app.json with it in "config" first.`);
|
|
124
|
+
}
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function call(fn, slug) {
|
|
130
|
+
try {
|
|
131
|
+
return await fn();
|
|
132
|
+
} catch (error) {
|
|
133
|
+
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.`);
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function describeStored(stored) {
|
|
139
|
+
if (!stored) return 'not set';
|
|
140
|
+
const when = stored.updatedAt ? ` (${stored.updatedAt.slice(0, 16).replace('T', ' ')}${stored.updatedBy ? ` by ${stored.updatedBy}` : ''})` : '';
|
|
141
|
+
return `${formatValue(stored.value)}${stored.valid === false ? ' ! invalid for its type: ignored' : ''}${when}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A value for the terminal: emails as a list, the rest as JSON, cut at 100 characters. */
|
|
145
|
+
function formatValue(value) {
|
|
146
|
+
const text = Array.isArray(value) && value.every((v) => typeof v === 'string' && v.includes('@')) && value.length ? value.join(', ') : JSON.stringify(value);
|
|
147
|
+
return text.length > 100 ? `${text.slice(0, 99)}…` : text;
|
|
148
|
+
}
|
package/src/create.mjs
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { basename, join, relative, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { planAgentFiles, writeAgentFiles } from './agents.mjs';
|
|
7
|
+
import { credentialsPath } from './credentials.mjs';
|
|
8
|
+
import { ENV_EXAMPLE, resolveSettings } from './env.mjs';
|
|
9
|
+
import { newManifest } from './manifest.mjs';
|
|
10
|
+
import { DEFAULT_ICON_SVG } from './app-icon.mjs';
|
|
11
|
+
import { applyChannel, CHANNELS, cliChannel, DEV_CHANNEL_NOTE, planChannel, writeCdnFiles } from './channel.mjs';
|
|
12
|
+
|
|
13
|
+
const TEMPLATES_DIR = fileURLToPath(new URL('../templates', import.meta.url));
|
|
14
|
+
|
|
15
|
+
export const TEMPLATES = {
|
|
16
|
+
react: { description: 'React + Vite + TypeScript + the UI kit (Spark\'s stylesheet and elements)', build: 'dist', dev: 'npm run dev', devUrl: (slug) => `http://localhost:3100/${slug}/` },
|
|
17
|
+
preact: { description: 'Preact + Vite + TypeScript (small bundle) + the UI kit (stylesheet and elements)', build: 'dist', dev: 'npm run dev', devUrl: (slug) => `http://localhost:3100/${slug}/` },
|
|
18
|
+
next: { description: 'Next.js (static export) + TypeScript + the UI kit (stylesheet and elements)', build: 'out', dev: 'npm run dev', devUrl: (slug) => `http://localhost:3100/${slug}/` },
|
|
19
|
+
'next-mui': {
|
|
20
|
+
description: 'Next.js (static export) + Material UI 9 + the UI kit (Spark\'s theme and components)',
|
|
21
|
+
build: 'out',
|
|
22
|
+
dev: 'npm run dev',
|
|
23
|
+
devUrl: (slug) => `http://localhost:3100/${slug}/`,
|
|
24
|
+
},
|
|
25
|
+
vanilla: { description: 'Plain HTML + JavaScript, no build step (the bridge and the UI kit from the CDN)', build: 'public', dev: 'npm run dev', devUrl: () => 'http://localhost:3100/' },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function toSlug(value) {
|
|
29
|
+
return (
|
|
30
|
+
String(value)
|
|
31
|
+
.toLowerCase()
|
|
32
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
33
|
+
.replace(/^-+|-+$/g, '')
|
|
34
|
+
.slice(0, 64) || 'my-app'
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function titleCase(slug) {
|
|
39
|
+
return slug
|
|
40
|
+
.split('-')
|
|
41
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
42
|
+
.join(' ');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Scaffolds a new Hawk App project from one of the built-in templates. */
|
|
46
|
+
/** @param checkSlug Refuses a taken slug before anything is written (create's own check: see slug-guard.mjs). */
|
|
47
|
+
/**
|
|
48
|
+
* @param channel `latest` or `dev`: which builds of the bridge and CLI the app follows. Default: this CLI's own channel,
|
|
49
|
+
* so `npx …-cli@dev create` makes a dev app (see channel.mjs).
|
|
50
|
+
*/
|
|
51
|
+
export async function createProject({ target, template = 'react', name, install = false, checkSlug, channel = cliChannel(), log = console.log, credentialsFile = credentialsPath() }) {
|
|
52
|
+
if (!CHANNELS.includes(channel)) throw new Error(`--channel must be latest or dev, not "${channel}".`);
|
|
53
|
+
if (!target) throw new Error(`Usage: hawk-app create <folder> [--template ${Object.keys(TEMPLATES).join('|')}] [--name "Display Name"] [--install]`);
|
|
54
|
+
const spec = TEMPLATES[template];
|
|
55
|
+
if (!spec) throw new Error(`Unknown template "${template}". Choose one of: ${Object.keys(TEMPLATES).join(', ')}.`);
|
|
56
|
+
|
|
57
|
+
const dir = resolve(target);
|
|
58
|
+
if (existsSync(dir) && (await readdir(dir)).length > 0) throw new Error(`"${target}" already exists and is not empty.`);
|
|
59
|
+
const slug = toSlug(basename(dir));
|
|
60
|
+
if (checkSlug) await checkSlug(slug);
|
|
61
|
+
const displayName = name ?? titleCase(slug);
|
|
62
|
+
const cliVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
63
|
+
const replacements = { __SLUG__: slug, __NAME__: displayName, __CLI_VERSION__: cliVersion };
|
|
64
|
+
|
|
65
|
+
const source = join(TEMPLATES_DIR, template);
|
|
66
|
+
const written = [];
|
|
67
|
+
for (const file of await listTemplateFiles(source)) {
|
|
68
|
+
const content = (await readFile(join(source, file), 'utf8')).replace(/__SLUG__|__NAME__|__CLI_VERSION__/g, (m) => replacements[m]);
|
|
69
|
+
await writeOut(join(dir, file), content);
|
|
70
|
+
written.push(file);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A dev app: the Hawk packages (and the vanilla CDN import) on the dev tag, not the release the template pins.
|
|
74
|
+
if (channel === 'dev') {
|
|
75
|
+
const pkgFile = join(dir, 'package.json');
|
|
76
|
+
const appJsFile = join(dir, 'public', 'app.js');
|
|
77
|
+
const pkg = existsSync(pkgFile) ? JSON.parse(await readFile(pkgFile, 'utf8')) : null;
|
|
78
|
+
const appJs = existsSync(appJsFile) ? await readFile(appJsFile, 'utf8') : null;
|
|
79
|
+
const plan = planChannel({ pkg, appJs, channel, latestVersion: () => null });
|
|
80
|
+
applyChannel(plan, pkg ?? {}, appJs);
|
|
81
|
+
if (pkg && plan.dependencies.length) await writeOut(pkgFile, JSON.stringify(pkg, null, 2) + '\n');
|
|
82
|
+
// The bridge and the kit from the CDN (app.js, index.html).
|
|
83
|
+
if (plan.cdn) await writeCdnFiles(dir, plan.cdn.to);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
await writeOut(join(dir, 'hawk-app.json'), JSON.stringify(newManifest({ slug, name: displayName, template }), null, 2) + '\n');
|
|
87
|
+
await writeOut(join(dir, 'icon.svg'), DEFAULT_ICON_SVG);
|
|
88
|
+
await writeOut(join(dir, '.gitignore'), ['node_modules/', 'dist/', 'out/', '.next/', 'next-env.d.ts', '.env', '.env.local', '.env.*.local', '*.log', ''].join('\n'));
|
|
89
|
+
await writeOut(join(dir, '.env.example'), ENV_EXAMPLE);
|
|
90
|
+
await writeOut(join(dir, 'README.md'), readme({ slug, displayName, template, spec }));
|
|
91
|
+
const agentFiles = await writeAgentFiles(dir, planAgentFiles(dir, { slug, name: displayName, template }));
|
|
92
|
+
|
|
93
|
+
log(`✔ Created ${displayName} (${spec.description}) in ${relative(process.cwd(), dir) || '.'}`);
|
|
94
|
+
log(` slug: ${slug} → each deploy is served at <apps CDN>/${slug}/v<n>/`);
|
|
95
|
+
log(' AGENTS.md tells AI coding assistants (Claude Code, Copilot, Cursor, …) how Hawk Apps work.');
|
|
96
|
+
if (channel === 'dev') DEV_CHANNEL_NOTE.forEach((line) => log(` ${line}`));
|
|
97
|
+
|
|
98
|
+
if (install) {
|
|
99
|
+
log(' Installing dependencies…');
|
|
100
|
+
const result = spawnSync('npm', ['install', '--no-audit', '--no-fund'], { cwd: dir, stdio: 'inherit', shell: process.platform === 'win32' });
|
|
101
|
+
if (result.status !== 0) throw new Error('npm install failed. Run it yourself in the new folder.');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
log('\nNext steps:');
|
|
105
|
+
log(` cd ${relative(process.cwd(), dir) || '.'}`);
|
|
106
|
+
if (!install) log(' npm install');
|
|
107
|
+
const { key, url } = resolveSettings({ dir, credentialsFile });
|
|
108
|
+
if (!key) log(' npx hawk-app sso (once per machine: sign in with your Bridgeline account)');
|
|
109
|
+
log(" npm run app:dev (run it locally inside a stand-in for HawkSearch, with a client's data)");
|
|
110
|
+
log(` npm run app:deploy (${template === 'vanilla' ? 'uploads public/' : 'builds and uploads'} a new version, with hawk-app.json)`);
|
|
111
|
+
log(key ? `\n Logged in to ${url}. Check everything any time with \`npx hawk-app doctor\`.` : '');
|
|
112
|
+
log(' It isn\'t released yet: only Bridgeline staff see it, from the Apps page in HawkSearch (`npx hawk-app open`). Submit it');
|
|
113
|
+
log(' for approval there; an approver releases it. `npx hawk-app --help` lists everything else.');
|
|
114
|
+
log(' Docs: https://developers.hawksearchapps.com (npx hawk-app docs), with your Bridgeline account.');
|
|
115
|
+
return { dir, slug, files: [...written, 'hawk-app.json', 'icon.svg', '.gitignore', '.env.example', 'README.md', ...agentFiles] };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function listTemplateFiles(root) {
|
|
119
|
+
const files = [];
|
|
120
|
+
async function walk(current) {
|
|
121
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
122
|
+
const full = join(current, entry.name);
|
|
123
|
+
if (entry.isDirectory()) await walk(full);
|
|
124
|
+
else files.push(relative(root, full));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
await walk(root);
|
|
128
|
+
return files.sort();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function writeOut(path, content) {
|
|
132
|
+
await mkdir(resolve(path, '..'), { recursive: true });
|
|
133
|
+
await writeFile(path, content);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readme({ slug, displayName, template, spec }) {
|
|
137
|
+
return `# ${displayName}
|
|
138
|
+
|
|
139
|
+
A Hawk App: a tool that runs inside HawkSearch (today's dashboard, Spark later) and gets its context and data through
|
|
140
|
+
[\`@bridgeline-digital/hawk-app-bridge\`](https://www.npmjs.com/package/@bridgeline-digital/hawk-app-bridge).
|
|
141
|
+
Created with \`hawk-app create --template ${template}\` (${spec.description}).
|
|
142
|
+
|
|
143
|
+
## Develop
|
|
144
|
+
\`\`\`bash
|
|
145
|
+
npm install
|
|
146
|
+
npx hawk-app sso # once per machine: sign in with your Bridgeline account
|
|
147
|
+
npm run app:dev # the app inside a local stand-in for HawkSearch, with a client's data
|
|
148
|
+
\`\`\`
|
|
149
|
+
\`app:dev\` runs \`${spec.dev}\` and a stand-in for HawkSearch at http://localhost:3200 with real tokens and data. The
|
|
150
|
+
first time it asks which client (remembered in \`.env.local\`; \`-- --client <id>\` picks another) and registers the
|
|
151
|
+
app (not released). Opened directly in a browser, the app says "Open this app from the Apps page in HawkSearch": it
|
|
152
|
+
needs a host for its context and data. Queries and functions run from the deployed version: deploy after changing them.
|
|
153
|
+
|
|
154
|
+
To try a local copy inside the real HawkSearch instead, open
|
|
155
|
+
\`<HawkSearch>/admin/apps/${slug}?devUrl=${spec.devUrl(slug)}\` (Bridgeline users, \`http://localhost\` ports 3100-3103).
|
|
156
|
+
|
|
157
|
+
## Data
|
|
158
|
+
Use the bridge. There are **no API keys in the app**; the platform adds credentials server-side for the current client.
|
|
159
|
+
- \`app.analytics.report('searches' | 'queries' | 'poor-results' | …, { limit })\` needs the \`reports:read\` scope.
|
|
160
|
+
- \`app.dashboard.get('Synonym')\` (HawkSearch dashboard API, read) needs the \`hawksearch:read\` scope.
|
|
161
|
+
- \`app.dashboard.post/put/delete('Synonym', body)\` (writes: synonyms, redirects, banners, landing pages, …) need the \`hawksearch:write\` scope; writes are never retried.
|
|
162
|
+
- \`app.context\` and \`app.onContextChange\` give the client, engine, date range, locale and theme.
|
|
163
|
+
- Own backend? Send \`await app.getToken()\` as \`Authorization: Bearer ...\` and verify it there against the
|
|
164
|
+
platform's \`/.well-known/jwks.json\`. Don't use cookies: browsers block them inside the host's iframe.
|
|
165
|
+
|
|
166
|
+
## Ship
|
|
167
|
+
\`\`\`bash
|
|
168
|
+
npm run app:deploy # ${template === 'vanilla' ? 'upload public/' : `build and upload ${spec.build}/`} as a new version, with hawk-app.json
|
|
169
|
+
npx hawk-app status # released or not, the newest version, the approval request
|
|
170
|
+
npx hawk-app open # the app in HawkSearch, to review it and submit it for approval
|
|
171
|
+
npx hawk-app clients add <id> # give a client the app (or manage clients in the Hawk Analytics portal)
|
|
172
|
+
\`\`\`
|
|
173
|
+
Every deploy is a new, numbered version${template === 'vanilla' ? '' : `: \`app:deploy\` builds the app with its base path set to \`/${slug}/v<n>\``},
|
|
174
|
+
with the settings in \`hawk-app.json\` (scopes, origins, views, functions…).
|
|
175
|
+
|
|
176
|
+
- **Not released at first.** A new app's versions are drafts: only Bridgeline staff see them. Submit a version for
|
|
177
|
+
approval in HawkSearch; once an approver releases it, the app's clients get it.
|
|
178
|
+
- **Each version is reviewed.** Clients keep the released version until a new one is approved. \`npx hawk-app versions\`
|
|
179
|
+
lists every version with its status; \`npx hawk-app why <client>\` explains what a client sees.
|
|
180
|
+
- **Test before release.** \`npm run app:deploy -- --alias staging\` deploys a version for an alias (e.g. new clients or a
|
|
181
|
+
branch) without touching the main line. See versions-and-aliases in the Hawk Apps docs.
|
|
182
|
+
- **Clients any time.** \`npx hawk-app clients add|exclude|remove <id>\` (or the Hawk Analytics portal) applies at once,
|
|
183
|
+
without a deploy or approval, and is recorded in the app's history.
|
|
184
|
+
- **Something off?** \`npx hawk-app doctor\` checks your login, the platform, the project and the bridge.
|
|
185
|
+
`;
|
|
186
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { chmod, mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Per-user credentials, shared by every app on this machine:
|
|
8
|
+
* Windows %APPDATA%\hawk-app\credentials.json
|
|
9
|
+
* macOS / Linux $XDG_CONFIG_HOME/hawk-app/credentials.json (default ~/.config/hawk-app/credentials.json)
|
|
10
|
+
* HAWK_APPS_CREDENTIALS_FILE overrides the location.
|
|
11
|
+
*/
|
|
12
|
+
export function credentialsPath(env = process.env, platform = process.platform, home = homedir()) {
|
|
13
|
+
if (env.HAWK_APPS_CREDENTIALS_FILE) return env.HAWK_APPS_CREDENTIALS_FILE;
|
|
14
|
+
const base = platform === 'win32' ? env.APPDATA ?? join(home, 'AppData', 'Roaming') : env.XDG_CONFIG_HOME ?? join(home, '.config');
|
|
15
|
+
return join(base, 'hawk-app', 'credentials.json');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function normalizeUrl(url) {
|
|
19
|
+
return String(url ?? '').trim().replace(/\/+$/, '');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @returns {{ platforms: Record<string, { publisherKey: string, savedAt?: string }> }} */
|
|
23
|
+
export function readCredentials(path) {
|
|
24
|
+
try {
|
|
25
|
+
const data = JSON.parse(readFileSync(path, 'utf8'));
|
|
26
|
+
return { platforms: data.platforms ?? {} };
|
|
27
|
+
} catch {
|
|
28
|
+
return { platforms: {} };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function writeCredentials(path, data) {
|
|
33
|
+
await mkdir(dirname(path), { recursive: true });
|
|
34
|
+
await writeFile(path, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
35
|
+
// Owner-only on macOS/Linux; on Windows the file lives in the user's own profile.
|
|
36
|
+
await chmod(path, 0o600).catch(() => undefined);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Saves the key for a platform. It doesn't choose the platform commands use (see resolveSettings). */
|
|
40
|
+
export async function saveCredential(path, url, publisherKey) {
|
|
41
|
+
const data = readCredentials(path);
|
|
42
|
+
const platform = normalizeUrl(url);
|
|
43
|
+
data.platforms[platform] = { publisherKey, savedAt: new Date().toISOString() };
|
|
44
|
+
await writeCredentials(path, data);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @returns {boolean} whether a key was removed. */
|
|
48
|
+
export async function removeCredential(path, url) {
|
|
49
|
+
if (!existsSync(path)) return false;
|
|
50
|
+
const data = readCredentials(path);
|
|
51
|
+
const platform = normalizeUrl(url);
|
|
52
|
+
if (!data.platforms[platform]) return false;
|
|
53
|
+
delete data.platforms[platform];
|
|
54
|
+
await writeCredentials(path, data);
|
|
55
|
+
return true;
|
|
56
|
+
}
|