@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
@@ -1,91 +1,91 @@
1
- import { readdir, readFile, stat } from 'node:fs/promises';
2
- import { extname, join, relative } from 'node:path';
3
-
4
- /**
5
- * Which scopes the app's code needs: the bridge (and function) calls it makes, matched in its source files. A call whose
6
- * scope hawk-app.json lacks fails at run time with 403 scope_missing; `validate` and `deploy` say so first. Matching is
7
- * by the call's shape (`.ai.generate(`, `.storage.put(`…), so it works whatever the app calls its bridge object, and it
8
- * only warns: a false hit costs a line, a miss is no worse than today.
9
- */
10
- const RULES = [
11
- { scope: 'reports:read', pattern: /\.analytics\.report\s*\(/ },
12
- { scope: 'hawksearch:read', pattern: /\.dashboard\.get\s*\(/ },
13
- { scope: 'hawksearch:write', pattern: /\.dashboard\.(post|put|delete)\s*\(/ },
14
- { scope: 'data:read', pattern: /\.data\.query\s*\(/ },
15
- { scope: 'functions:invoke', pattern: /\.functions\.(call|start|job|wait)\s*\(/ },
16
- { scope: 'email:send', pattern: /\.email\.send\s*\(/ },
17
- { scope: 'hawksearch:search', pattern: /\.hawksearch\.(search|autocomplete)\s*\(/ },
18
- { scope: 'hawksearch:sysparams:write', pattern: /\.hawksearch\.systemParameters\.(list|get|set)\s*\(/ },
19
- { scope: 'hawksearch:features:write', pattern: /\.hawksearch\.features\.(list|set|setFacet)\s*\(/ },
20
- { scope: 'hawksearch:index:items', pattern: /\.hawksearch\.index\.items\.\w+\s*\(/ },
21
- { scope: 'hawksearch:index', pattern: /\.hawksearch\.index\.(rebuild|status|wait|indexes|current)\s*\(/ },
22
- { scope: 'hawksearch:index:manage', pattern: /\.hawksearch\.index\.(create|setCurrent|deleteIndex|rebuildAll)\s*\(/ },
23
- { scope: 'hawksearch:recommendations', pattern: /\.hawksearch\.recommendations\s*\(/ },
24
- { scope: 'storage', pattern: /\.storage\.(get|put|delete|list|batch|usage)\s*\(|\.storage\.files\.\w+\s*\(/ },
25
- { scope: 'lambdas:invoke', pattern: /\.lambdas\.invoke\s*\(/ },
26
- { scope: 'ai:invoke', pattern: /\.ai\.(generate|stream|info)\s*\(/ },
27
- { scope: 'assistant', pattern: /\.assistant\.(ask|setContext)\s*\(/ },
28
- ];
29
-
30
- const SOURCE = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.vue', '.svelte', '.html']);
31
- const SKIP = new Set(['node_modules', '.git', '.next', 'dist', 'out', 'build', 'coverage', '.turbo', '.vercel']);
32
- const MAX_FILES = 3000;
33
- const MAX_BYTES = 1024 * 1024;
34
-
35
- /** Source files of the project (not dependencies or build output), at most MAX_FILES. */
36
- async function sourceFiles(dir, found = []) {
37
- let entries;
38
- try {
39
- entries = await readdir(dir, { withFileTypes: true });
40
- } catch {
41
- return found;
42
- }
43
- for (const entry of entries) {
44
- if (found.length >= MAX_FILES) break;
45
- if (entry.name.startsWith('.') && entry.name !== '.') continue;
46
- const full = join(dir, entry.name);
47
- if (entry.isDirectory()) {
48
- if (!SKIP.has(entry.name)) await sourceFiles(full, found);
49
- } else if (SOURCE.has(extname(entry.name))) {
50
- found.push(full);
51
- }
52
- }
53
- return found;
54
- }
55
-
56
- /**
57
- * The scopes the code uses that `scopes` lacks: [{ scope, file, line }] (the first place each one is used), relative to
58
- * `projectDir`.
59
- */
60
- export async function missingScopes(projectDir, scopes = []) {
61
- const declared = new Set(scopes);
62
- const wanted = RULES.filter((rule) => !declared.has(rule.scope));
63
- if (!wanted.length) return [];
64
- const found = new Map();
65
- for (const file of await sourceFiles(projectDir)) {
66
- if (found.size === wanted.length) break;
67
- let text;
68
- try {
69
- if ((await stat(file)).size > MAX_BYTES) continue;
70
- text = await readFile(file, 'utf8');
71
- } catch {
72
- continue;
73
- }
74
- for (const rule of wanted) {
75
- if (found.has(rule.scope)) continue;
76
- const match = rule.pattern.exec(text);
77
- if (match) {
78
- const line = text.slice(0, match.index).split('\n').length;
79
- found.set(rule.scope, { scope: rule.scope, file: relative(projectDir, file).split('\\').join('/'), line });
80
- }
81
- }
82
- }
83
- return [...found.values()];
84
- }
85
-
86
- /** The warning for what's missing (null: nothing). */
87
- export function describeMissingScopes(missing) {
88
- if (!missing.length) return null;
89
- const where = missing.map((m) => `${m.scope} (${m.file}:${m.line})`).join(', ');
90
- return `The code uses ${where}, which "scopes" in hawk-app.json doesn't have: add ${missing.length === 1 ? 'it' : 'them'}, or those calls fail with 403 scope_missing.`;
91
- }
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { extname, join, relative } from 'node:path';
3
+
4
+ /**
5
+ * Which scopes the app's code needs: the bridge (and function) calls it makes, matched in its source files. A call whose
6
+ * scope hawk-app.json lacks fails at run time with 403 scope_missing; `validate` and `deploy` say so first. Matching is
7
+ * by the call's shape (`.ai.generate(`, `.storage.put(`…), so it works whatever the app calls its bridge object, and it
8
+ * only warns: a false hit costs a line, a miss is no worse than today.
9
+ */
10
+ const RULES = [
11
+ { scope: 'reports:read', pattern: /\.analytics\.report\s*\(/ },
12
+ { scope: 'hawksearch:read', pattern: /\.dashboard\.get\s*\(/ },
13
+ { scope: 'hawksearch:write', pattern: /\.dashboard\.(post|put|delete)\s*\(/ },
14
+ { scope: 'data:read', pattern: /\.data\.query\s*\(/ },
15
+ { scope: 'functions:invoke', pattern: /\.functions\.(call|start|job|wait)\s*\(/ },
16
+ { scope: 'email:send', pattern: /\.email\.send\s*\(/ },
17
+ { scope: 'hawksearch:search', pattern: /\.hawksearch\.(search|autocomplete)\s*\(/ },
18
+ { scope: 'hawksearch:sysparams:write', pattern: /\.hawksearch\.systemParameters\.(list|get|set)\s*\(/ },
19
+ { scope: 'hawksearch:features:write', pattern: /\.hawksearch\.features\.(list|set|setFacet)\s*\(/ },
20
+ { scope: 'hawksearch:index:items', pattern: /\.hawksearch\.index\.items\.\w+\s*\(/ },
21
+ { scope: 'hawksearch:index', pattern: /\.hawksearch\.index\.(rebuild|status|wait|indexes|current)\s*\(/ },
22
+ { scope: 'hawksearch:index:manage', pattern: /\.hawksearch\.index\.(create|setCurrent|deleteIndex|rebuildAll)\s*\(/ },
23
+ { scope: 'hawksearch:recommendations', pattern: /\.hawksearch\.recommendations\s*\(/ },
24
+ { scope: 'storage', pattern: /\.storage\.(get|put|delete|list|batch|usage)\s*\(|\.storage\.files\.\w+\s*\(/ },
25
+ { scope: 'lambdas:invoke', pattern: /\.lambdas\.invoke\s*\(/ },
26
+ { scope: 'ai:invoke', pattern: /\.ai\.(generate|stream|info)\s*\(/ },
27
+ { scope: 'assistant', pattern: /\.assistant\.(ask|setContext)\s*\(/ },
28
+ ];
29
+
30
+ const SOURCE = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.vue', '.svelte', '.html']);
31
+ const SKIP = new Set(['node_modules', '.git', '.next', 'dist', 'out', 'build', 'coverage', '.turbo', '.vercel']);
32
+ const MAX_FILES = 3000;
33
+ const MAX_BYTES = 1024 * 1024;
34
+
35
+ /** Source files of the project (not dependencies or build output), at most MAX_FILES. */
36
+ async function sourceFiles(dir, found = []) {
37
+ let entries;
38
+ try {
39
+ entries = await readdir(dir, { withFileTypes: true });
40
+ } catch {
41
+ return found;
42
+ }
43
+ for (const entry of entries) {
44
+ if (found.length >= MAX_FILES) break;
45
+ if (entry.name.startsWith('.') && entry.name !== '.') continue;
46
+ const full = join(dir, entry.name);
47
+ if (entry.isDirectory()) {
48
+ if (!SKIP.has(entry.name)) await sourceFiles(full, found);
49
+ } else if (SOURCE.has(extname(entry.name))) {
50
+ found.push(full);
51
+ }
52
+ }
53
+ return found;
54
+ }
55
+
56
+ /**
57
+ * The scopes the code uses that `scopes` lacks: [{ scope, file, line }] (the first place each one is used), relative to
58
+ * `projectDir`.
59
+ */
60
+ export async function missingScopes(projectDir, scopes = []) {
61
+ const declared = new Set(scopes);
62
+ const wanted = RULES.filter((rule) => !declared.has(rule.scope));
63
+ if (!wanted.length) return [];
64
+ const found = new Map();
65
+ for (const file of await sourceFiles(projectDir)) {
66
+ if (found.size === wanted.length) break;
67
+ let text;
68
+ try {
69
+ if ((await stat(file)).size > MAX_BYTES) continue;
70
+ text = await readFile(file, 'utf8');
71
+ } catch {
72
+ continue;
73
+ }
74
+ for (const rule of wanted) {
75
+ if (found.has(rule.scope)) continue;
76
+ const match = rule.pattern.exec(text);
77
+ if (match) {
78
+ const line = text.slice(0, match.index).split('\n').length;
79
+ found.set(rule.scope, { scope: rule.scope, file: relative(projectDir, file).split('\\').join('/'), line });
80
+ }
81
+ }
82
+ }
83
+ return [...found.values()];
84
+ }
85
+
86
+ /** The warning for what's missing (null: nothing). */
87
+ export function describeMissingScopes(missing) {
88
+ if (!missing.length) return null;
89
+ const where = missing.map((m) => `${m.scope} (${m.file}:${m.line})`).join(', ');
90
+ return `The code uses ${where}, which "scopes" in hawk-app.json doesn't have: add ${missing.length === 1 ? 'it' : 'them'}, or those calls fail with 403 scope_missing.`;
91
+ }
package/src/secrets.mjs CHANGED
@@ -1,86 +1,86 @@
1
- import { SECRET_NAME } from './functions.mjs';
2
- import { promptHidden } from './login.mjs';
3
-
4
- /** The platform keeps each secret in a Standard-tier Parameter Store SecureString: at most 4 KB (UTF-8). */
5
- export const MAX_SECRET_BYTES = 4 * 1024;
6
-
7
- const USAGE = `Usage:
8
- hawk-app secrets list
9
- hawk-app secrets set <NAME> [--value-from-env <VAR>]
10
- hawk-app secrets delete <NAME>`;
11
-
12
- /**
13
- * `hawk-app secrets …`: the app's function secrets, stored by the platform (never in the app or its versions).
14
- * Values are typed with hidden input (or read from an environment variable) and never printed. `client` is a
15
- * factory, so a usage mistake shows before any login check.
16
- */
17
- export async function secretsCommand({ client, manifest, sub, args = [], flags = {}, prompt = promptHidden, env = process.env, log = console.log }) {
18
- const slug = manifest.slug;
19
- switch (sub) {
20
- case 'list': {
21
- const { secrets = [] } = await client().listSecrets(slug);
22
- const declared = declaredSecrets(manifest);
23
- if (!secrets.length) log(`${slug} has no secrets yet. Set one with \`hawk-app secrets set <NAME>\`.`);
24
- else {
25
- log(`${secrets.length} secret${secrets.length === 1 ? '' : 's'} for ${slug} (values are never shown):`);
26
- const width = Math.max(...secrets.map((s) => s.name.length));
27
- for (const secret of secrets) {
28
- const users = declared.get(secret.name);
29
- log(` ${secret.name.padEnd(width)} ${secret.updatedAt ? `updated ${secret.updatedAt.slice(0, 16).replace('T', ' ')}` : ''}${users ? ` used by ${users.join(', ')}` : ' (not used by any function)'}`);
30
- }
31
- }
32
- const missing = [...declared.keys()].filter((name) => !secrets.some((s) => s.name === name));
33
- for (const name of missing) log(`! ${name} is declared by ${declared.get(name).join(', ')} but not set: calls fail with secret_missing until you set it.`);
34
- return 0;
35
- }
36
-
37
- case 'set': {
38
- const name = checkName(args[0]);
39
- let value;
40
- const from = flags['value-from-env'];
41
- if (from !== undefined) {
42
- if (typeof from !== 'string' || !from) throw new Error('--value-from-env needs the name of an environment variable.');
43
- value = env[from];
44
- if (value === undefined || value === '') throw new Error(`Environment variable ${from} is not set (or empty).`);
45
- } else {
46
- value = await prompt(`Value for ${name} (input hidden): `, {
47
- notInteractive: `\`hawk-app secrets set\` needs an interactive terminal to type the value. In CI, pass --value-from-env <VAR>.`,
48
- });
49
- if (!value) throw new Error('No value entered; nothing was changed.');
50
- }
51
- if (Buffer.byteLength(value, 'utf8') > MAX_SECRET_BYTES) throw new Error(`The value is larger than ${MAX_SECRET_BYTES / 1024} KB.`);
52
- await client().setSecret(slug, name, value);
53
- log(`✔ Set ${name} for ${slug}. It applies to every version at once.`);
54
- if (!declaredSecrets(manifest).has(name)) log(`! No function in hawk-app.json declares ${name}: add it to a function's "secrets" to receive it as ctx.secrets.${name}.`);
55
- return 0;
56
- }
57
-
58
- case 'delete': {
59
- const name = checkName(args[0]);
60
- await client().deleteSecret(slug, name);
61
- log(`✔ Deleted ${name} for ${slug}.`);
62
- const users = declaredSecrets(manifest).get(name);
63
- if (users) log(`! ${users.join(', ')} still declare${users.length === 1 ? 's' : ''} ${name}: calls fail with secret_missing until it's set again.`);
64
- return 0;
65
- }
66
-
67
- default:
68
- throw new Error(sub ? `Unknown secrets command "${sub}".\n${USAGE}` : USAGE);
69
- }
70
- }
71
-
72
- function checkName(name) {
73
- if (!name) throw new Error(USAGE);
74
- if (!SECRET_NAME.test(name)) throw new Error(`"${name}" is not a valid secret name: UPPER_SNAKE_CASE, a letter first, up to 64 characters (e.g. FEED_API_KEY).`);
75
- return name;
76
- }
77
-
78
- /** Secret name → the functions in the manifest that declare it. */
79
- function declaredSecrets(manifest) {
80
- const map = new Map();
81
- const functions = manifest.functions && typeof manifest.functions === 'object' ? manifest.functions : {};
82
- for (const [fn, config] of Object.entries(functions)) {
83
- for (const secret of Array.isArray(config?.secrets) ? config.secrets : []) map.set(secret, [...(map.get(secret) ?? []), fn]);
84
- }
85
- return map;
86
- }
1
+ import { SECRET_NAME } from './functions.mjs';
2
+ import { promptHidden } from './login.mjs';
3
+
4
+ /** The platform keeps each secret in a Standard-tier Parameter Store SecureString: at most 4 KB (UTF-8). */
5
+ export const MAX_SECRET_BYTES = 4 * 1024;
6
+
7
+ const USAGE = `Usage:
8
+ hawk-app secrets list
9
+ hawk-app secrets set <NAME> [--value-from-env <VAR>]
10
+ hawk-app secrets delete <NAME>`;
11
+
12
+ /**
13
+ * `hawk-app secrets …`: the app's function secrets, stored by the platform (never in the app or its versions).
14
+ * Values are typed with hidden input (or read from an environment variable) and never printed. `client` is a
15
+ * factory, so a usage mistake shows before any login check.
16
+ */
17
+ export async function secretsCommand({ client, manifest, sub, args = [], flags = {}, prompt = promptHidden, env = process.env, log = console.log }) {
18
+ const slug = manifest.slug;
19
+ switch (sub) {
20
+ case 'list': {
21
+ const { secrets = [] } = await client().listSecrets(slug);
22
+ const declared = declaredSecrets(manifest);
23
+ if (!secrets.length) log(`${slug} has no secrets yet. Set one with \`hawk-app secrets set <NAME>\`.`);
24
+ else {
25
+ log(`${secrets.length} secret${secrets.length === 1 ? '' : 's'} for ${slug} (values are never shown):`);
26
+ const width = Math.max(...secrets.map((s) => s.name.length));
27
+ for (const secret of secrets) {
28
+ const users = declared.get(secret.name);
29
+ log(` ${secret.name.padEnd(width)} ${secret.updatedAt ? `updated ${secret.updatedAt.slice(0, 16).replace('T', ' ')}` : ''}${users ? ` used by ${users.join(', ')}` : ' (not used by any function)'}`);
30
+ }
31
+ }
32
+ const missing = [...declared.keys()].filter((name) => !secrets.some((s) => s.name === name));
33
+ for (const name of missing) log(`! ${name} is declared by ${declared.get(name).join(', ')} but not set: calls fail with secret_missing until you set it.`);
34
+ return 0;
35
+ }
36
+
37
+ case 'set': {
38
+ const name = checkName(args[0]);
39
+ let value;
40
+ const from = flags['value-from-env'];
41
+ if (from !== undefined) {
42
+ if (typeof from !== 'string' || !from) throw new Error('--value-from-env needs the name of an environment variable.');
43
+ value = env[from];
44
+ if (value === undefined || value === '') throw new Error(`Environment variable ${from} is not set (or empty).`);
45
+ } else {
46
+ value = await prompt(`Value for ${name} (input hidden): `, {
47
+ notInteractive: `\`hawk-app secrets set\` needs an interactive terminal to type the value. In CI, pass --value-from-env <VAR>.`,
48
+ });
49
+ if (!value) throw new Error('No value entered; nothing was changed.');
50
+ }
51
+ if (Buffer.byteLength(value, 'utf8') > MAX_SECRET_BYTES) throw new Error(`The value is larger than ${MAX_SECRET_BYTES / 1024} KB.`);
52
+ await client().setSecret(slug, name, value);
53
+ log(`✔ Set ${name} for ${slug}. It applies to every version at once.`);
54
+ if (!declaredSecrets(manifest).has(name)) log(`! No function in hawk-app.json declares ${name}: add it to a function's "secrets" to receive it as ctx.secrets.${name}.`);
55
+ return 0;
56
+ }
57
+
58
+ case 'delete': {
59
+ const name = checkName(args[0]);
60
+ await client().deleteSecret(slug, name);
61
+ log(`✔ Deleted ${name} for ${slug}.`);
62
+ const users = declaredSecrets(manifest).get(name);
63
+ if (users) log(`! ${users.join(', ')} still declare${users.length === 1 ? 's' : ''} ${name}: calls fail with secret_missing until it's set again.`);
64
+ return 0;
65
+ }
66
+
67
+ default:
68
+ throw new Error(sub ? `Unknown secrets command "${sub}".\n${USAGE}` : USAGE);
69
+ }
70
+ }
71
+
72
+ function checkName(name) {
73
+ if (!name) throw new Error(USAGE);
74
+ if (!SECRET_NAME.test(name)) throw new Error(`"${name}" is not a valid secret name: UPPER_SNAKE_CASE, a letter first, up to 64 characters (e.g. FEED_API_KEY).`);
75
+ return name;
76
+ }
77
+
78
+ /** Secret name → the functions in the manifest that declare it. */
79
+ function declaredSecrets(manifest) {
80
+ const map = new Map();
81
+ const functions = manifest.functions && typeof manifest.functions === 'object' ? manifest.functions : {};
82
+ for (const [fn, config] of Object.entries(functions)) {
83
+ for (const secret of Array.isArray(config?.secrets) ? config.secrets : []) map.set(secret, [...(map.get(secret) ?? []), fn]);
84
+ }
85
+ return map;
86
+ }
@@ -1,80 +1,80 @@
1
- // Keeps two developers from working on one app by accident. Apps belong to a publisher (a team), and every person on
2
- // the team can register and deploy any of its apps, so a reused slug would silently mix two codebases into one app.
3
- // - create / init: refuse a slug that's already taken (unless --yes).
4
- // - register / deploy: when someone else's key made the app's last change, ask before going on (--yes skips it; a
5
- // pipeline, with no terminal to ask in, gets a warning and goes on).
6
- import { createInterface } from 'node:readline';
7
-
8
- /**
9
- * What the platform says about a slug for the caller's key: free (404), another publisher's (409 slug_owned), or this
10
- * publisher's app with its last change. Null when it can't tell (not logged in, platform unreachable, other errors).
11
- */
12
- export async function slugStatus(client, slug) {
13
- try {
14
- const app = await client.status(slug);
15
- return { state: 'taken', app };
16
- } catch (error) {
17
- if (error.status === 404) return { state: 'free' };
18
- if (error.status === 409) return { state: 'other' };
19
- return null;
20
- }
21
- }
22
-
23
- const describeChange = (last) => {
24
- const who = last.name || last.keyId || 'a shared key';
25
- const when = last.at ? new Date(last.at).toLocaleString() : 'earlier';
26
- return `${last.kind === 'deploy' ? 'deployed' : 'registered'} by ${who} on ${when}`;
27
- };
28
-
29
- /** create / init: throws when the slug is taken; logs a note when it couldn't check. */
30
- export async function checkNewSlug({ client, slug, yes = false, log = console.log }) {
31
- if (!client) {
32
- log(` (Not logged in, so "${slug}" wasn't checked. \`hawk-app register\` will tell you if it's taken.)`);
33
- return;
34
- }
35
- const status = await slugStatus(client, slug);
36
- if (!status) {
37
- log(` (Couldn't check whether "${slug}" is free; \`hawk-app register\` will tell you if it's taken.)`);
38
- return;
39
- }
40
- if (status.state === 'other') {
41
- throw new Error(`"${slug}" belongs to another publisher. Pick another name (the folder name, or --slug).`);
42
- }
43
- if (status.state === 'taken' && !yes) {
44
- const last = status.app.lastChange;
45
- throw new Error(
46
- `"${slug}" is already an app${last ? `, last ${describeChange(last)}` : ''}. Pick another name, or work in that app's own repository.\n` +
47
- ' To set up this folder for that existing app anyway, add --yes.',
48
- );
49
- }
50
- }
51
-
52
- /** Asks a yes/no question on the terminal; null when there is no terminal to ask in. */
53
- export function askYesNo(question, { input = process.stdin, output = process.stdout } = {}) {
54
- if (!input.isTTY) return Promise.resolve(null);
55
- return new Promise((resolve) => {
56
- const rl = createInterface({ input, output });
57
- rl.question(`${question} (y/N) `, (answer) => {
58
- rl.close();
59
- resolve(/^y(es)?$/i.test(answer.trim()));
60
- });
61
- });
62
- }
63
-
64
- /**
65
- * register / deploy: when the app's last change was made with someone else's key, asks before going on. Throws
66
- * "Cancelled." on no; with no terminal (CI) it warns and goes on; --yes goes on quietly.
67
- */
68
- export async function confirmTakeover({ client, slug, action, yes = false, confirm = askYesNo, log = console.log }) {
69
- const status = await slugStatus(client, slug);
70
- const last = status?.state === 'taken' ? status.app.lastChange : null;
71
- if (!last || last.yours || yes) return;
72
-
73
- const message = `"${slug}" was last ${describeChange(last)}.`;
74
- const answer = await confirm(`${message} ${action === 'deploy' ? 'Deploy a new version of it' : 'Register your hawk-app.json over it'}?`);
75
- if (answer === null) {
76
- log(`! ${message} Going on (no terminal to ask; add --yes to silence this).`);
77
- return;
78
- }
79
- if (!answer) throw new Error('Cancelled. Nothing was changed.');
80
- }
1
+ // Keeps two developers from working on one app by accident. Apps belong to a publisher (a team), and every person on
2
+ // the team can register and deploy any of its apps, so a reused slug would silently mix two codebases into one app.
3
+ // - create / init: refuse a slug that's already taken (unless --yes).
4
+ // - register / deploy: when someone else's key made the app's last change, ask before going on (--yes skips it; a
5
+ // pipeline, with no terminal to ask in, gets a warning and goes on).
6
+ import { createInterface } from 'node:readline';
7
+
8
+ /**
9
+ * What the platform says about a slug for the caller's key: free (404), another publisher's (409 slug_owned), or this
10
+ * publisher's app with its last change. Null when it can't tell (not logged in, platform unreachable, other errors).
11
+ */
12
+ export async function slugStatus(client, slug) {
13
+ try {
14
+ const app = await client.status(slug);
15
+ return { state: 'taken', app };
16
+ } catch (error) {
17
+ if (error.status === 404) return { state: 'free' };
18
+ if (error.status === 409) return { state: 'other' };
19
+ return null;
20
+ }
21
+ }
22
+
23
+ const describeChange = (last) => {
24
+ const who = last.name || last.keyId || 'a shared key';
25
+ const when = last.at ? new Date(last.at).toLocaleString() : 'earlier';
26
+ return `${last.kind === 'deploy' ? 'deployed' : 'registered'} by ${who} on ${when}`;
27
+ };
28
+
29
+ /** create / init: throws when the slug is taken; logs a note when it couldn't check. */
30
+ export async function checkNewSlug({ client, slug, yes = false, log = console.log }) {
31
+ if (!client) {
32
+ log(` (Not logged in, so "${slug}" wasn't checked. \`hawk-app register\` will tell you if it's taken.)`);
33
+ return;
34
+ }
35
+ const status = await slugStatus(client, slug);
36
+ if (!status) {
37
+ log(` (Couldn't check whether "${slug}" is free; \`hawk-app register\` will tell you if it's taken.)`);
38
+ return;
39
+ }
40
+ if (status.state === 'other') {
41
+ throw new Error(`"${slug}" belongs to another publisher. Pick another name (the folder name, or --slug).`);
42
+ }
43
+ if (status.state === 'taken' && !yes) {
44
+ const last = status.app.lastChange;
45
+ throw new Error(
46
+ `"${slug}" is already an app${last ? `, last ${describeChange(last)}` : ''}. Pick another name, or work in that app's own repository.\n` +
47
+ ' To set up this folder for that existing app anyway, add --yes.',
48
+ );
49
+ }
50
+ }
51
+
52
+ /** Asks a yes/no question on the terminal; null when there is no terminal to ask in. */
53
+ export function askYesNo(question, { input = process.stdin, output = process.stdout } = {}) {
54
+ if (!input.isTTY) return Promise.resolve(null);
55
+ return new Promise((resolve) => {
56
+ const rl = createInterface({ input, output });
57
+ rl.question(`${question} (y/N) `, (answer) => {
58
+ rl.close();
59
+ resolve(/^y(es)?$/i.test(answer.trim()));
60
+ });
61
+ });
62
+ }
63
+
64
+ /**
65
+ * register / deploy: when the app's last change was made with someone else's key, asks before going on. Throws
66
+ * "Cancelled." on no; with no terminal (CI) it warns and goes on; --yes goes on quietly.
67
+ */
68
+ export async function confirmTakeover({ client, slug, action, yes = false, confirm = askYesNo, log = console.log }) {
69
+ const status = await slugStatus(client, slug);
70
+ const last = status?.state === 'taken' ? status.app.lastChange : null;
71
+ if (!last || last.yours || yes) return;
72
+
73
+ const message = `"${slug}" was last ${describeChange(last)}.`;
74
+ const answer = await confirm(`${message} ${action === 'deploy' ? 'Deploy a new version of it' : 'Register your hawk-app.json over it'}?`);
75
+ if (answer === null) {
76
+ log(`! ${message} Going on (no terminal to ask; add --yes to silence this).`);
77
+ return;
78
+ }
79
+ if (!answer) throw new Error('Cancelled. Nothing was changed.');
80
+ }