@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/login.mjs CHANGED
@@ -1,166 +1,166 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { existsSync, readFileSync } from 'node:fs';
3
- import { appendFile, writeFile } from 'node:fs/promises';
4
- import { join } from 'node:path';
5
- import { credentialsPath, removeCredential, saveCredential } from './credentials.mjs';
6
- import { describePlatform, resolveSettings } from './env.mjs';
7
-
8
- /** Reads a line from the terminal without echoing it. */
9
- export function promptHidden(
10
- question,
11
- {
12
- input = process.stdin,
13
- output = process.stdout,
14
- notInteractive = '`hawk-app login` needs an interactive terminal. In CI, set HAWK_APPS_PUBLISHER_KEY as a secured variable instead.',
15
- } = {},
16
- ) {
17
- if (!input.isTTY) {
18
- return Promise.reject(new Error(notInteractive));
19
- }
20
- return new Promise((resolve, reject) => {
21
- let value = '';
22
- output.write(question);
23
- input.setRawMode(true);
24
- input.resume();
25
- input.setEncoding('utf8');
26
- const onData = (chunk) => {
27
- for (const ch of chunk) {
28
- if (ch === '\r' || ch === '\n') {
29
- finish();
30
- return resolve(value.trim());
31
- }
32
- if (ch === '\u0003') {
33
- finish();
34
- return reject(new Error('Cancelled.'));
35
- }
36
- if (ch === '\u007f' || ch === '\b') value = value.slice(0, -1);
37
- else if (ch >= ' ') value += ch;
38
- }
39
- };
40
- function finish() {
41
- input.off('data', onData);
42
- input.setRawMode(false);
43
- input.pause();
44
- output.write('\n');
45
- }
46
- input.on('data', onData);
47
- });
48
- }
49
-
50
- /** Sets or replaces KEY=value lines in a .env file, keeping everything else (comments, other keys) as is. */
51
- export function upsertEnv(text, values) {
52
- const lines = text ? text.split(/\r?\n/) : [];
53
- const pending = { ...values };
54
- const updated = lines.map((line) => {
55
- const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
56
- if (match && match[1] in pending) {
57
- const next = `${match[1]}=${pending[match[1]]}`;
58
- delete pending[match[1]];
59
- return next;
60
- }
61
- return line;
62
- });
63
- while (updated.length && updated[updated.length - 1] === '') updated.pop();
64
- for (const [key, value] of Object.entries(pending)) updated.push(`${key}=${value}`);
65
- return updated.join('\n') + '\n';
66
- }
67
-
68
- /** Checks a key with the platform: 404 = accepted (no app by that name), 401 = rejected. */
69
- export async function checkKey(url, key, fetchImpl = fetch) {
70
- let response;
71
- try {
72
- response = await fetchImpl(`${url}/publish/apps/login-check`, { headers: { Authorization: `PublisherKey ${key}` } });
73
- } catch (error) {
74
- return { ok: false, reason: `Could not reach ${url}: ${error.message}.` };
75
- }
76
- if (response.status === 401) return { ok: false, reason: 'The platform rejected the key.' };
77
- if (response.status !== 404 && !response.ok) return { ok: false, reason: `Unexpected response from the platform (${response.status}).` };
78
- return { ok: true };
79
- }
80
-
81
- const publisherOf = (key) => key.split('.')[0];
82
-
83
- /**
84
- * Asks for the publisher key, checks it with the platform and saves it once for this machine, so every app
85
- * uses it. With `local: true` it goes into this project's .env.local instead. The key never appears on
86
- * screen, in shell history or as a command-line argument.
87
- */
88
- export async function login({
89
- dir = process.cwd(),
90
- platform,
91
- env,
92
- local = false,
93
- prompt = promptHidden,
94
- fetchImpl = fetch,
95
- log = console.log,
96
- isIgnored = gitIgnored,
97
- credentialsFile = credentialsPath(),
98
- }) {
99
- const { url } = resolveSettings({ flagPlatform: platform, flagEnv: env, dir, credentialsFile });
100
-
101
- log(`Platform: ${describePlatform(url)}`);
102
- const key = await prompt('Publisher key (input hidden): ');
103
- if (!/^[a-z0-9-]+\.[A-Za-z0-9_-]{16,}$/.test(key)) throw new Error('That does not look like a publisher key ("<publisherId>.<secret>"). Nothing was saved.');
104
- const check = await checkKey(url, key, fetchImpl);
105
- if (!check.ok) throw new Error(`${check.reason} Nothing was saved.`);
106
-
107
- if (!local) {
108
- await saveCredential(credentialsFile, url, key);
109
- log(`✔ Logged in to ${describePlatform(url)} as publisher "${publisherOf(key)}".`);
110
- log(` Saved for every app on this machine: ${credentialsFile}`);
111
- return;
112
- }
113
-
114
- const envPath = join(dir, '.env.local');
115
- const existing = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
116
- await writeFile(envPath, upsertEnv(existing, { HAWK_APPS_PLATFORM_URL: url, HAWK_APPS_PUBLISHER_KEY: key }));
117
- log(`✔ Logged in to ${url} as publisher "${publisherOf(key)}" for this project only (.env.local).`);
118
- if (isIgnored(dir, '.env.local') === false || (isIgnored(dir, '.env.local') === undefined && !ignoredByGitignoreFile(dir))) {
119
- const gitignore = join(dir, '.gitignore');
120
- const needsNewline = existsSync(gitignore) && !readFileSync(gitignore, 'utf8').endsWith('\n');
121
- await appendFile(gitignore, `${needsNewline ? '\n' : ''}.env.local\n`);
122
- log(' Added .env.local to .gitignore so the key is never committed.');
123
- }
124
- }
125
-
126
- /** Shows which platform and key would be used here, and whether the platform accepts the key. */
127
- export async function whoami({ dir = process.cwd(), platform, env, fetchImpl = fetch, log = console.log, credentialsFile = credentialsPath() }) {
128
- const settings = resolveSettings({ flagPlatform: platform, flagEnv: env, dir, credentialsFile });
129
- log(`Platform: ${describePlatform(settings.url)} (from ${settings.urlSource})`);
130
- if (!settings.key) {
131
- log('✖ Not logged in. Run `npx hawk-app sso` (Bridgeline account) or `npx hawk-app login` (publisher key) once on this machine.');
132
- return 1;
133
- }
134
- const check = await checkKey(settings.url, settings.key, fetchImpl);
135
- if (!check.ok) {
136
- log(`✖ ${check.reason} Key from ${settings.keySource}. Run \`npx hawk-app sso\` or \`npx hawk-app login\` again.`);
137
- return 1;
138
- }
139
- log(`✔ Logged in as publisher "${publisherOf(settings.key)}" (key from ${settings.keySource})`);
140
- return 0;
141
- }
142
-
143
- /** Removes this machine's saved key for the platform. Keys in .env.local or the environment are not touched. */
144
- export async function logout({ dir = process.cwd(), platform, env, log = console.log, credentialsFile = credentialsPath() }) {
145
- const { url } = resolveSettings({ flagPlatform: platform, flagEnv: env, dir, credentialsFile });
146
- if (await removeCredential(credentialsFile, url)) log(`✔ Logged out of ${url} on this machine.`);
147
- else log(`Not logged in to ${url} on this machine.`);
148
- const remaining = resolveSettings({ flagPlatform: platform, flagEnv: env, dir, credentialsFile });
149
- if (remaining.key) log(` Note: a key is still set in ${remaining.keySource}; that one still applies here.`);
150
- return 0;
151
- }
152
-
153
- function ignoredByGitignoreFile(dir) {
154
- const path = join(dir, '.gitignore');
155
- if (!existsSync(path)) return false;
156
- return readFileSync(path, 'utf8')
157
- .split(/\r?\n/)
158
- .map((l) => l.trim())
159
- .some((l) => l === '.env.local' || l === '.env*' || l === '.env.*' || l === '*.local');
160
- }
161
-
162
- function gitIgnored(dir, file) {
163
- const result = spawnSync('git', ['check-ignore', '-q', file], { cwd: dir, stdio: 'ignore' });
164
- if (result.error || result.status === 128) return undefined;
165
- return result.status === 0;
166
- }
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { appendFile, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { credentialsPath, removeCredential, saveCredential } from './credentials.mjs';
6
+ import { describePlatform, resolveSettings } from './env.mjs';
7
+
8
+ /** Reads a line from the terminal without echoing it. */
9
+ export function promptHidden(
10
+ question,
11
+ {
12
+ input = process.stdin,
13
+ output = process.stdout,
14
+ notInteractive = '`hawk-app login` needs an interactive terminal. In CI, set HAWK_APPS_PUBLISHER_KEY as a secured variable instead.',
15
+ } = {},
16
+ ) {
17
+ if (!input.isTTY) {
18
+ return Promise.reject(new Error(notInteractive));
19
+ }
20
+ return new Promise((resolve, reject) => {
21
+ let value = '';
22
+ output.write(question);
23
+ input.setRawMode(true);
24
+ input.resume();
25
+ input.setEncoding('utf8');
26
+ const onData = (chunk) => {
27
+ for (const ch of chunk) {
28
+ if (ch === '\r' || ch === '\n') {
29
+ finish();
30
+ return resolve(value.trim());
31
+ }
32
+ if (ch === '\u0003') {
33
+ finish();
34
+ return reject(new Error('Cancelled.'));
35
+ }
36
+ if (ch === '\u007f' || ch === '\b') value = value.slice(0, -1);
37
+ else if (ch >= ' ') value += ch;
38
+ }
39
+ };
40
+ function finish() {
41
+ input.off('data', onData);
42
+ input.setRawMode(false);
43
+ input.pause();
44
+ output.write('\n');
45
+ }
46
+ input.on('data', onData);
47
+ });
48
+ }
49
+
50
+ /** Sets or replaces KEY=value lines in a .env file, keeping everything else (comments, other keys) as is. */
51
+ export function upsertEnv(text, values) {
52
+ const lines = text ? text.split(/\r?\n/) : [];
53
+ const pending = { ...values };
54
+ const updated = lines.map((line) => {
55
+ const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
56
+ if (match && match[1] in pending) {
57
+ const next = `${match[1]}=${pending[match[1]]}`;
58
+ delete pending[match[1]];
59
+ return next;
60
+ }
61
+ return line;
62
+ });
63
+ while (updated.length && updated[updated.length - 1] === '') updated.pop();
64
+ for (const [key, value] of Object.entries(pending)) updated.push(`${key}=${value}`);
65
+ return updated.join('\n') + '\n';
66
+ }
67
+
68
+ /** Checks a key with the platform: 404 = accepted (no app by that name), 401 = rejected. */
69
+ export async function checkKey(url, key, fetchImpl = fetch) {
70
+ let response;
71
+ try {
72
+ response = await fetchImpl(`${url}/publish/apps/login-check`, { headers: { Authorization: `PublisherKey ${key}` } });
73
+ } catch (error) {
74
+ return { ok: false, reason: `Could not reach ${url}: ${error.message}.` };
75
+ }
76
+ if (response.status === 401) return { ok: false, reason: 'The platform rejected the key.' };
77
+ if (response.status !== 404 && !response.ok) return { ok: false, reason: `Unexpected response from the platform (${response.status}).` };
78
+ return { ok: true };
79
+ }
80
+
81
+ const publisherOf = (key) => key.split('.')[0];
82
+
83
+ /**
84
+ * Asks for the publisher key, checks it with the platform and saves it once for this machine, so every app
85
+ * uses it. With `local: true` it goes into this project's .env.local instead. The key never appears on
86
+ * screen, in shell history or as a command-line argument.
87
+ */
88
+ export async function login({
89
+ dir = process.cwd(),
90
+ platform,
91
+ env,
92
+ local = false,
93
+ prompt = promptHidden,
94
+ fetchImpl = fetch,
95
+ log = console.log,
96
+ isIgnored = gitIgnored,
97
+ credentialsFile = credentialsPath(),
98
+ }) {
99
+ const { url } = resolveSettings({ flagPlatform: platform, flagEnv: env, dir, credentialsFile });
100
+
101
+ log(`Platform: ${describePlatform(url)}`);
102
+ const key = await prompt('Publisher key (input hidden): ');
103
+ if (!/^[a-z0-9-]+\.[A-Za-z0-9_-]{16,}$/.test(key)) throw new Error('That does not look like a publisher key ("<publisherId>.<secret>"). Nothing was saved.');
104
+ const check = await checkKey(url, key, fetchImpl);
105
+ if (!check.ok) throw new Error(`${check.reason} Nothing was saved.`);
106
+
107
+ if (!local) {
108
+ await saveCredential(credentialsFile, url, key);
109
+ log(`✔ Logged in to ${describePlatform(url)} as publisher "${publisherOf(key)}".`);
110
+ log(` Saved for every app on this machine: ${credentialsFile}`);
111
+ return;
112
+ }
113
+
114
+ const envPath = join(dir, '.env.local');
115
+ const existing = existsSync(envPath) ? readFileSync(envPath, 'utf8') : '';
116
+ await writeFile(envPath, upsertEnv(existing, { HAWK_APPS_PLATFORM_URL: url, HAWK_APPS_PUBLISHER_KEY: key }));
117
+ log(`✔ Logged in to ${url} as publisher "${publisherOf(key)}" for this project only (.env.local).`);
118
+ if (isIgnored(dir, '.env.local') === false || (isIgnored(dir, '.env.local') === undefined && !ignoredByGitignoreFile(dir))) {
119
+ const gitignore = join(dir, '.gitignore');
120
+ const needsNewline = existsSync(gitignore) && !readFileSync(gitignore, 'utf8').endsWith('\n');
121
+ await appendFile(gitignore, `${needsNewline ? '\n' : ''}.env.local\n`);
122
+ log(' Added .env.local to .gitignore so the key is never committed.');
123
+ }
124
+ }
125
+
126
+ /** Shows which platform and key would be used here, and whether the platform accepts the key. */
127
+ export async function whoami({ dir = process.cwd(), platform, env, fetchImpl = fetch, log = console.log, credentialsFile = credentialsPath() }) {
128
+ const settings = resolveSettings({ flagPlatform: platform, flagEnv: env, dir, credentialsFile });
129
+ log(`Platform: ${describePlatform(settings.url)} (from ${settings.urlSource})`);
130
+ if (!settings.key) {
131
+ log('✖ Not logged in. Run `npx hawk-app sso` (Bridgeline account) or `npx hawk-app login` (publisher key) once on this machine.');
132
+ return 1;
133
+ }
134
+ const check = await checkKey(settings.url, settings.key, fetchImpl);
135
+ if (!check.ok) {
136
+ log(`✖ ${check.reason} Key from ${settings.keySource}. Run \`npx hawk-app sso\` or \`npx hawk-app login\` again.`);
137
+ return 1;
138
+ }
139
+ log(`✔ Logged in as publisher "${publisherOf(settings.key)}" (key from ${settings.keySource})`);
140
+ return 0;
141
+ }
142
+
143
+ /** Removes this machine's saved key for the platform. Keys in .env.local or the environment are not touched. */
144
+ export async function logout({ dir = process.cwd(), platform, env, log = console.log, credentialsFile = credentialsPath() }) {
145
+ const { url } = resolveSettings({ flagPlatform: platform, flagEnv: env, dir, credentialsFile });
146
+ if (await removeCredential(credentialsFile, url)) log(`✔ Logged out of ${url} on this machine.`);
147
+ else log(`Not logged in to ${url} on this machine.`);
148
+ const remaining = resolveSettings({ flagPlatform: platform, flagEnv: env, dir, credentialsFile });
149
+ if (remaining.key) log(` Note: a key is still set in ${remaining.keySource}; that one still applies here.`);
150
+ return 0;
151
+ }
152
+
153
+ function ignoredByGitignoreFile(dir) {
154
+ const path = join(dir, '.gitignore');
155
+ if (!existsSync(path)) return false;
156
+ return readFileSync(path, 'utf8')
157
+ .split(/\r?\n/)
158
+ .map((l) => l.trim())
159
+ .some((l) => l === '.env.local' || l === '.env*' || l === '.env.*' || l === '*.local');
160
+ }
161
+
162
+ function gitIgnored(dir, file) {
163
+ const result = spawnSync('git', ['check-ignore', '-q', file], { cwd: dir, stdio: 'ignore' });
164
+ if (result.error || result.status === 128) return undefined;
165
+ return result.status === 0;
166
+ }
package/src/logs.mjs CHANGED
@@ -1,64 +1,64 @@
1
- export const FOLLOW_INTERVAL_MS = 2_000;
2
- const SINCE = /^\d+[mhd]$/;
3
- const USAGE = 'Usage: hawk-app logs <function> [--since 15m|1h|1d|<ISO time>] [--follow]';
4
-
5
- const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
6
-
7
- /**
8
- * `hawk-app logs <function>`: prints the function's log events (all versions), page by page. With --follow it keeps
9
- * polling every 2 s (with the platform's nextToken, or from the last event's time) until Ctrl+C or `signal` aborts.
10
- */
11
- export async function logsCommand({ client, manifest, name, flags = {}, sleep = wait, signal, log = console.log }) {
12
- if (!name) throw new Error(USAGE);
13
- const declared = manifest.functions && typeof manifest.functions === 'object' ? Object.keys(manifest.functions) : [];
14
- if (!declared.includes(name)) {
15
- throw new Error(`"${name}" is not a function of ${manifest.slug}${declared.length ? ` (functions: ${declared.join(', ')})` : ' (hawk-app.json declares no functions)'}.`);
16
- }
17
- let since = flags.since;
18
- if (since !== undefined && (typeof since !== 'string' || (!SINCE.test(since) && Number.isNaN(Date.parse(since))))) {
19
- throw new Error(`--since takes a duration (15m, 1h, 1d) or an ISO time, not "${since}".`);
20
- }
21
- const follow = !!flags.follow;
22
- const platform = client();
23
-
24
- let nextToken;
25
- let printed = 0;
26
- // Events already printed at the latest timestamp, so polling again from that time doesn't repeat them.
27
- let lastTime;
28
- let seenAtLast = new Set();
29
- if (follow) log(`Following ${name} logs (Ctrl+C to stop)…`);
30
-
31
- while (!signal?.aborted) {
32
- const page = await platform.functionLogs(manifest.slug, name, { since, nextToken });
33
- for (const event of page.events ?? []) {
34
- const time = toIso(event.timestamp);
35
- const key = `${time}\n${event.message}`;
36
- if (time === lastTime && seenAtLast.has(key)) continue;
37
- if (time !== lastTime) {
38
- lastTime = time;
39
- seenAtLast = new Set();
40
- }
41
- seenAtLast.add(key);
42
- log(`${time} ${String(event.message ?? '').replace(/\s+$/, '')}`);
43
- printed += 1;
44
- }
45
- if (page.nextToken) {
46
- nextToken = page.nextToken;
47
- // More pages now; when following, the platform may also hand back a token to poll with.
48
- if (page.events?.length || !follow) continue;
49
- } else {
50
- nextToken = undefined;
51
- if (!follow) break;
52
- if (lastTime) since = lastTime;
53
- }
54
- await sleep(FOLLOW_INTERVAL_MS);
55
- }
56
- if (!follow && !printed) log(`No log events for ${name}${since ? ` since ${since}` : ''}.`);
57
- return 0;
58
- }
59
-
60
- function toIso(timestamp) {
61
- if (typeof timestamp === 'number') return new Date(timestamp).toISOString();
62
- const parsed = Date.parse(timestamp);
63
- return Number.isNaN(parsed) ? String(timestamp ?? '') : new Date(parsed).toISOString();
64
- }
1
+ export const FOLLOW_INTERVAL_MS = 2_000;
2
+ const SINCE = /^\d+[mhd]$/;
3
+ const USAGE = 'Usage: hawk-app logs <function> [--since 15m|1h|1d|<ISO time>] [--follow]';
4
+
5
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
6
+
7
+ /**
8
+ * `hawk-app logs <function>`: prints the function's log events (all versions), page by page. With --follow it keeps
9
+ * polling every 2 s (with the platform's nextToken, or from the last event's time) until Ctrl+C or `signal` aborts.
10
+ */
11
+ export async function logsCommand({ client, manifest, name, flags = {}, sleep = wait, signal, log = console.log }) {
12
+ if (!name) throw new Error(USAGE);
13
+ const declared = manifest.functions && typeof manifest.functions === 'object' ? Object.keys(manifest.functions) : [];
14
+ if (!declared.includes(name)) {
15
+ throw new Error(`"${name}" is not a function of ${manifest.slug}${declared.length ? ` (functions: ${declared.join(', ')})` : ' (hawk-app.json declares no functions)'}.`);
16
+ }
17
+ let since = flags.since;
18
+ if (since !== undefined && (typeof since !== 'string' || (!SINCE.test(since) && Number.isNaN(Date.parse(since))))) {
19
+ throw new Error(`--since takes a duration (15m, 1h, 1d) or an ISO time, not "${since}".`);
20
+ }
21
+ const follow = !!flags.follow;
22
+ const platform = client();
23
+
24
+ let nextToken;
25
+ let printed = 0;
26
+ // Events already printed at the latest timestamp, so polling again from that time doesn't repeat them.
27
+ let lastTime;
28
+ let seenAtLast = new Set();
29
+ if (follow) log(`Following ${name} logs (Ctrl+C to stop)…`);
30
+
31
+ while (!signal?.aborted) {
32
+ const page = await platform.functionLogs(manifest.slug, name, { since, nextToken });
33
+ for (const event of page.events ?? []) {
34
+ const time = toIso(event.timestamp);
35
+ const key = `${time}\n${event.message}`;
36
+ if (time === lastTime && seenAtLast.has(key)) continue;
37
+ if (time !== lastTime) {
38
+ lastTime = time;
39
+ seenAtLast = new Set();
40
+ }
41
+ seenAtLast.add(key);
42
+ log(`${time} ${String(event.message ?? '').replace(/\s+$/, '')}`);
43
+ printed += 1;
44
+ }
45
+ if (page.nextToken) {
46
+ nextToken = page.nextToken;
47
+ // More pages now; when following, the platform may also hand back a token to poll with.
48
+ if (page.events?.length || !follow) continue;
49
+ } else {
50
+ nextToken = undefined;
51
+ if (!follow) break;
52
+ if (lastTime) since = lastTime;
53
+ }
54
+ await sleep(FOLLOW_INTERVAL_MS);
55
+ }
56
+ if (!follow && !printed) log(`No log events for ${name}${since ? ` since ${since}` : ''}.`);
57
+ return 0;
58
+ }
59
+
60
+ function toIso(timestamp) {
61
+ if (typeof timestamp === 'number') return new Date(timestamp).toISOString();
62
+ const parsed = Date.parse(timestamp);
63
+ return Number.isNaN(parsed) ? String(timestamp ?? '') : new Date(parsed).toISOString();
64
+ }