watch-tail 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.
- package/LICENSE +21 -0
- package/README.md +210 -0
- package/build/client/_app/immutable/assets/0.B1NsRRrX.css +2 -0
- package/build/client/_app/immutable/assets/0.B1NsRRrX.css.br +0 -0
- package/build/client/_app/immutable/assets/0.B1NsRRrX.css.gz +0 -0
- package/build/client/_app/immutable/chunks/Bjy-W4x2.js +81 -0
- package/build/client/_app/immutable/chunks/Bjy-W4x2.js.br +0 -0
- package/build/client/_app/immutable/chunks/Bjy-W4x2.js.gz +0 -0
- package/build/client/_app/immutable/chunks/DCKWcE8r.js +1 -0
- package/build/client/_app/immutable/chunks/DCKWcE8r.js.br +0 -0
- package/build/client/_app/immutable/chunks/DCKWcE8r.js.gz +0 -0
- package/build/client/_app/immutable/chunks/DDRhiz1-.js +1 -0
- package/build/client/_app/immutable/chunks/DDRhiz1-.js.br +0 -0
- package/build/client/_app/immutable/chunks/DDRhiz1-.js.gz +0 -0
- package/build/client/_app/immutable/chunks/ZAD2y5xJ.js +3 -0
- package/build/client/_app/immutable/chunks/ZAD2y5xJ.js.br +0 -0
- package/build/client/_app/immutable/chunks/ZAD2y5xJ.js.gz +0 -0
- package/build/client/_app/immutable/chunks/xihTtKlq.js +1 -0
- package/build/client/_app/immutable/chunks/xihTtKlq.js.br +1 -0
- package/build/client/_app/immutable/chunks/xihTtKlq.js.gz +0 -0
- package/build/client/_app/immutable/entry/app.DIoggGwf.js +2 -0
- package/build/client/_app/immutable/entry/app.DIoggGwf.js.br +0 -0
- package/build/client/_app/immutable/entry/app.DIoggGwf.js.gz +0 -0
- package/build/client/_app/immutable/entry/start._YXgjuSB.js +1 -0
- package/build/client/_app/immutable/entry/start._YXgjuSB.js.br +0 -0
- package/build/client/_app/immutable/entry/start._YXgjuSB.js.gz +0 -0
- package/build/client/_app/immutable/nodes/0.D5bYt7iX.js +1 -0
- package/build/client/_app/immutable/nodes/0.D5bYt7iX.js.br +0 -0
- package/build/client/_app/immutable/nodes/0.D5bYt7iX.js.gz +0 -0
- package/build/client/_app/immutable/nodes/1.DBX83mYX.js +1 -0
- package/build/client/_app/immutable/nodes/1.DBX83mYX.js.br +0 -0
- package/build/client/_app/immutable/nodes/1.DBX83mYX.js.gz +0 -0
- package/build/client/_app/immutable/nodes/2.DiH-2moC.js +5 -0
- package/build/client/_app/immutable/nodes/2.DiH-2moC.js.br +0 -0
- package/build/client/_app/immutable/nodes/2.DiH-2moC.js.gz +0 -0
- package/build/client/_app/version.json +1 -0
- package/build/client/_app/version.json.br +0 -0
- package/build/client/_app/version.json.gz +0 -0
- package/build/client/robots.txt +3 -0
- package/build/client/robots.txt.br +0 -0
- package/build/client/robots.txt.gz +0 -0
- package/build/env.js +70 -0
- package/build/env.js.map +1 -0
- package/build/handler.js +24 -0
- package/build/handler.js.map +1 -0
- package/build/index.js +147 -0
- package/build/index.js.map +1 -0
- package/build/server/chunks/chunks/env.js-l0sNRNKZ.js +2 -0
- package/build/server/chunks/chunks/env.js-l0sNRNKZ.js.map +1 -0
- package/build/server/chunks/chunks/env2.js-D7xrfc37.js +316 -0
- package/build/server/chunks/chunks/env2.js-D7xrfc37.js.map +1 -0
- package/build/server/chunks/chunks/exports.js-CvFPPbaH.js +263 -0
- package/build/server/chunks/chunks/exports.js-CvFPPbaH.js.map +1 -0
- package/build/server/chunks/chunks/internal.js-DdmUz1r3.js +1191 -0
- package/build/server/chunks/chunks/internal.js-DdmUz1r3.js.map +1 -0
- package/build/server/chunks/chunks/internal2.js-DZ8ZzzjN.js +22 -0
- package/build/server/chunks/chunks/internal2.js-DZ8ZzzjN.js.map +1 -0
- package/build/server/chunks/chunks/regions.js-IQPMyP5C.js +52 -0
- package/build/server/chunks/chunks/regions.js-IQPMyP5C.js.map +1 -0
- package/build/server/chunks/chunks/server.js-ByNB8uti.js +3659 -0
- package/build/server/chunks/chunks/server.js-ByNB8uti.js.map +1 -0
- package/build/server/chunks/chunks/shared-server.js-Dn5XheXH.js +22 -0
- package/build/server/chunks/chunks/shared-server.js-Dn5XheXH.js.map +1 -0
- package/build/server/chunks/chunks/shared.js-CgjUIvRK.js +776 -0
- package/build/server/chunks/chunks/shared.js-CgjUIvRK.js.map +1 -0
- package/build/server/chunks/chunks/state.js-ClLTNPGs.js +86 -0
- package/build/server/chunks/chunks/state.js-ClLTNPGs.js.map +1 -0
- package/build/server/chunks/chunks/time-range.js-BLMT8aU3.js +102 -0
- package/build/server/chunks/chunks/time-range.js-BLMT8aU3.js.map +1 -0
- package/build/server/chunks/chunks/uneval.js-DaakSYFQ.js +488 -0
- package/build/server/chunks/chunks/uneval.js-DaakSYFQ.js.map +1 -0
- package/build/server/chunks/chunks/utils.js-BIlHjgGW.js +933 -0
- package/build/server/chunks/chunks/utils.js-BIlHjgGW.js.map +1 -0
- package/build/server/chunks/entries/endpoints/api/health/_server.ts.js-aiNZfkco.js +41 -0
- package/build/server/chunks/entries/endpoints/api/health/_server.ts.js-aiNZfkco.js.map +1 -0
- package/build/server/chunks/entries/endpoints/api/log-groups/_server.ts.js-D143-vRl.js +120 -0
- package/build/server/chunks/entries/endpoints/api/log-groups/_server.ts.js-D143-vRl.js.map +1 -0
- package/build/server/chunks/entries/endpoints/api/regions/_server.ts.js-CDNL7msU.js +88 -0
- package/build/server/chunks/entries/endpoints/api/regions/_server.ts.js-CDNL7msU.js.map +1 -0
- package/build/server/chunks/entries/endpoints/api/stream/_server.ts.js-D_i-xW6_.js +610 -0
- package/build/server/chunks/entries/endpoints/api/stream/_server.ts.js-D_i-xW6_.js.map +1 -0
- package/build/server/chunks/entries/fallbacks/error.svelte.js-qFLa8EVy.js +17 -0
- package/build/server/chunks/entries/fallbacks/error.svelte.js-qFLa8EVy.js.map +1 -0
- package/build/server/chunks/entries/pages/_layout.svelte.js-Bx7lXkAi.js +22 -0
- package/build/server/chunks/entries/pages/_layout.svelte.js-Bx7lXkAi.js.map +1 -0
- package/build/server/chunks/entries/pages/_page.svelte.js-BsvaKqbk.js +1264 -0
- package/build/server/chunks/entries/pages/_page.svelte.js-BsvaKqbk.js.map +1 -0
- package/build/server/chunks/handler-nVqa-QPz.js +1735 -0
- package/build/server/chunks/handler-nVqa-QPz.js.map +1 -0
- package/build/server/chunks/index.js-CtbhHm7L.js +3803 -0
- package/build/server/chunks/index.js-CtbhHm7L.js.map +1 -0
- package/build/server/chunks/manifest.js-BwUys8gK.js +70 -0
- package/build/server/chunks/manifest.js-BwUys8gK.js.map +1 -0
- package/build/server/chunks/nodes/0.js-Dl0J9d3p.js +9 -0
- package/build/server/chunks/nodes/0.js-Dl0J9d3p.js.map +1 -0
- package/build/server/chunks/nodes/1.js-dXZVW92o.js +9 -0
- package/build/server/chunks/nodes/1.js-dXZVW92o.js.map +1 -0
- package/build/server/chunks/nodes/2.js-aMMUenQc.js +9 -0
- package/build/server/chunks/nodes/2.js-aMMUenQc.js.map +1 -0
- package/build/shims.js +33 -0
- package/build/shims.js.map +1 -0
- package/dist/cli/bin.js +9 -0
- package/dist/cli/completions.js +99 -0
- package/dist/cli/index.js +183 -0
- package/dist/cli/options.js +171 -0
- package/dist/cli/server.js +126 -0
- package/dist/cli/ui.js +75 -0
- package/dist/lib/cli/aws.js +264 -0
- package/dist/lib/time-range.js +106 -0
- package/package.json +106 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process management for the CLI: locate the built SvelteKit server, start it
|
|
3
|
+
* with the resolved environment, wait until it answers, and open the browser.
|
|
4
|
+
*
|
|
5
|
+
* Every value that can be computed without side effects is a separate pure
|
|
6
|
+
* helper so it can be unit tested.
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { dirname, join, resolve } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
/** Where the adapter-node server lives inside the package. */
|
|
13
|
+
const SERVER_ENTRY = join('build', 'index.js');
|
|
14
|
+
/**
|
|
15
|
+
* Walks up from the compiled CLI file until it finds `build/index.js`.
|
|
16
|
+
*
|
|
17
|
+
* Works both from `dist/cli/` in an installed package and from `src/cli/` in
|
|
18
|
+
* the repository, and returns `null` when the app has not been built yet.
|
|
19
|
+
*/
|
|
20
|
+
export function findAppRoot(startDir, levels = 4) {
|
|
21
|
+
let current = resolve(startDir);
|
|
22
|
+
for (let depth = 0; depth <= levels; depth += 1) {
|
|
23
|
+
if (existsSync(join(current, SERVER_ENTRY)) && existsSync(join(current, 'package.json'))) {
|
|
24
|
+
return current;
|
|
25
|
+
}
|
|
26
|
+
const parent = dirname(current);
|
|
27
|
+
if (parent === current)
|
|
28
|
+
break;
|
|
29
|
+
current = parent;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
/** App root as seen from this module. */
|
|
34
|
+
export function appRootFromHere(metaUrl = import.meta.url) {
|
|
35
|
+
return findAppRoot(dirname(fileURLToPath(metaUrl)));
|
|
36
|
+
}
|
|
37
|
+
/** The URL the UI is served on, as a user would type it. */
|
|
38
|
+
export function uiUrl(host, port) {
|
|
39
|
+
const display = host === '0.0.0.0' || host === '::' ? 'localhost' : host;
|
|
40
|
+
return `http://${display}:${port}`;
|
|
41
|
+
}
|
|
42
|
+
/** Health endpoint used to detect readiness. */
|
|
43
|
+
export function healthUrl(baseUrl) {
|
|
44
|
+
return new URL('/api/health', baseUrl).toString();
|
|
45
|
+
}
|
|
46
|
+
/** Command that opens `url` in the platform browser. */
|
|
47
|
+
export function browserCommand(url, platform = process.platform) {
|
|
48
|
+
if (platform === 'darwin')
|
|
49
|
+
return { command: 'open', args: [url] };
|
|
50
|
+
if (platform === 'win32')
|
|
51
|
+
return { command: 'cmd', args: ['/c', 'start', '', url] };
|
|
52
|
+
return { command: 'xdg-open', args: [url] };
|
|
53
|
+
}
|
|
54
|
+
/** Starts the production server and returns its handle. */
|
|
55
|
+
export function startServer(input) {
|
|
56
|
+
const env = {
|
|
57
|
+
...input.env,
|
|
58
|
+
PORT: String(input.port),
|
|
59
|
+
HOST: input.host,
|
|
60
|
+
ORIGIN: uiUrl(input.host, input.port),
|
|
61
|
+
};
|
|
62
|
+
return spawn(process.execPath, [join(input.appRoot, SERVER_ENTRY)], {
|
|
63
|
+
cwd: input.appRoot,
|
|
64
|
+
env,
|
|
65
|
+
// Without --verbose the server's stdout is noise: the CLI already reports
|
|
66
|
+
// readiness. stderr stays connected so real failures are never swallowed.
|
|
67
|
+
stdio: input.verbose === true ? 'inherit' : ['ignore', 'ignore', 'inherit'],
|
|
68
|
+
shell: false,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Polls `/api/health` until the server answers or the timeout expires.
|
|
73
|
+
*
|
|
74
|
+
* The endpoint reports the app's own state, so readiness does not depend on AWS
|
|
75
|
+
* credentials being valid.
|
|
76
|
+
*/
|
|
77
|
+
export async function waitForHealth(url, options = {}) {
|
|
78
|
+
const { timeoutMs = 20_000, intervalMs = 150, fetchImpl = fetch, signal } = options;
|
|
79
|
+
const deadline = Date.now() + timeoutMs;
|
|
80
|
+
while (Date.now() < deadline) {
|
|
81
|
+
if (signal?.aborted === true)
|
|
82
|
+
return false;
|
|
83
|
+
try {
|
|
84
|
+
const response = await fetchImpl(url, { signal: signal ?? AbortSignal.timeout(2000) });
|
|
85
|
+
if (response.ok)
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Not up yet (or still compiling): keep polling.
|
|
90
|
+
}
|
|
91
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, intervalMs));
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
/** Opens `url` in the browser, ignoring failures (headless machines). */
|
|
96
|
+
export function openBrowser(url, options = {}) {
|
|
97
|
+
const { platform = process.platform, spawnImpl = spawn } = options;
|
|
98
|
+
const { command, args } = browserCommand(url, platform);
|
|
99
|
+
try {
|
|
100
|
+
const child = spawnImpl(command, args, { stdio: 'ignore', detached: true, shell: false });
|
|
101
|
+
child.on('error', () => undefined);
|
|
102
|
+
child.unref();
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// Opening a browser is a convenience, never a failure.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Stops the server: SIGTERM first, SIGKILL after `graceMs`.
|
|
110
|
+
*
|
|
111
|
+
* Resolves with the exit code the CLI should report.
|
|
112
|
+
*/
|
|
113
|
+
export function stopServer(child, graceMs = 3000) {
|
|
114
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
115
|
+
return Promise.resolve(child.exitCode ?? 0);
|
|
116
|
+
}
|
|
117
|
+
return new Promise((resolveExit) => {
|
|
118
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), graceMs);
|
|
119
|
+
timer.unref?.();
|
|
120
|
+
child.once('close', (code, signal) => {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
resolveExit(code ?? (signal === null ? 0 : 130));
|
|
123
|
+
});
|
|
124
|
+
child.kill('SIGTERM');
|
|
125
|
+
});
|
|
126
|
+
}
|
package/dist/cli/ui.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal presentation for the CLI.
|
|
3
|
+
*
|
|
4
|
+
* Interactive terminals get the Bombshell (`@clack/prompts`) experience -
|
|
5
|
+
* banner, spinner, autocomplete prompts. Anything else (pipes, CI, `--no-color`)
|
|
6
|
+
* falls back to plain lines, which also keeps the integration tests readable.
|
|
7
|
+
*/
|
|
8
|
+
import { autocomplete, cancel, intro, isCancel, log, outro, spinner } from '@clack/prompts';
|
|
9
|
+
/** Decides whether the process can render animated output and ask questions. */
|
|
10
|
+
export function isInteractive(env = process.env) {
|
|
11
|
+
if (env.CI !== undefined && env.CI !== '' && env.CI !== 'false')
|
|
12
|
+
return false;
|
|
13
|
+
if (env.WATCH_TAIL_PLAIN === '1')
|
|
14
|
+
return false;
|
|
15
|
+
if (env.NO_COLOR !== undefined && env.NO_COLOR !== '')
|
|
16
|
+
return false;
|
|
17
|
+
return process.stdout.isTTY === true && process.stdin.isTTY === true;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Builds the presentation layer.
|
|
21
|
+
*
|
|
22
|
+
* The non-interactive branch never touches `@clack/prompts`, so piping the CLI
|
|
23
|
+
* into a file or a test harness produces stable, greppable output.
|
|
24
|
+
*/
|
|
25
|
+
export function createUi(options = {}) {
|
|
26
|
+
const interactive = options.interactive ?? isInteractive();
|
|
27
|
+
const spin = interactive ? spinner() : null;
|
|
28
|
+
if (!interactive) {
|
|
29
|
+
return {
|
|
30
|
+
interactive,
|
|
31
|
+
intro: (text) => console.log(text),
|
|
32
|
+
outro: (text) => console.log(text),
|
|
33
|
+
info: (text) => console.log(text),
|
|
34
|
+
warn: (text) => console.warn(`warning: ${text}`),
|
|
35
|
+
startSpinner: (text) => console.log(text),
|
|
36
|
+
stopSpinner: (text) => console.log(text),
|
|
37
|
+
failSpinner: (text) => console.error(text),
|
|
38
|
+
choose: async (_message, choices, initial) => {
|
|
39
|
+
const fallback = initial ?? choices[0]?.value ?? null;
|
|
40
|
+
return fallback;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
interactive,
|
|
46
|
+
intro: (text) => intro(text),
|
|
47
|
+
outro: (text) => outro(text),
|
|
48
|
+
info: (text) => log.info(text),
|
|
49
|
+
warn: (text) => log.warn(text),
|
|
50
|
+
startSpinner: (text) => spin?.start(text),
|
|
51
|
+
stopSpinner: (text) => spin?.stop(text),
|
|
52
|
+
failSpinner: (text) => {
|
|
53
|
+
spin?.stop();
|
|
54
|
+
log.error(text);
|
|
55
|
+
},
|
|
56
|
+
choose: async (message, choices, initial) => {
|
|
57
|
+
if (choices.length === 0)
|
|
58
|
+
return null;
|
|
59
|
+
const answer = await autocomplete({
|
|
60
|
+
message,
|
|
61
|
+
options: choices.map((choice) => ({
|
|
62
|
+
value: choice.value,
|
|
63
|
+
label: choice.label ?? choice.value,
|
|
64
|
+
})),
|
|
65
|
+
initialValue: initial ?? undefined,
|
|
66
|
+
maxItems: 12,
|
|
67
|
+
});
|
|
68
|
+
if (isCancel(answer)) {
|
|
69
|
+
cancel('Cancelled.');
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return String(answer);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS profile, region and child-environment helpers shared by the `watch-tail`
|
|
3
|
+
* CLI and the development launcher (`scripts/dev.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Everything here is pure apart from reading `~/.aws/config` and
|
|
6
|
+
* `~/.aws/credentials`, so it can be unit tested without spawning anything and
|
|
7
|
+
* reused by both entry points.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
/** Name of the git-ignored file with the local emulator settings. */
|
|
13
|
+
export const LOCAL_ENV_FILE = '.env.local';
|
|
14
|
+
/**
|
|
15
|
+
* Variables that point a run at a local emulator or at static keys.
|
|
16
|
+
*
|
|
17
|
+
* A blank value behaves as "unset" everywhere: the SDK's endpoint override
|
|
18
|
+
* ignores an empty string, and its environment credential provider skips blank
|
|
19
|
+
* keys, so blanking is enough to hand the run back to the ambient profile.
|
|
20
|
+
*/
|
|
21
|
+
export const LOCAL_OVERRIDE_KEYS = [
|
|
22
|
+
'AWS_ENDPOINT_URL',
|
|
23
|
+
'AWS_ENDPOINT_URL_LOGS',
|
|
24
|
+
'AWS_ACCESS_KEY_ID',
|
|
25
|
+
'AWS_SECRET_ACCESS_KEY',
|
|
26
|
+
'AWS_SESSION_TOKEN',
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* `.env.local` also pins `AWS_REGION=us-east-1`, and `readEnv()` merges
|
|
30
|
+
* `process.env` last, so a `--profile` run would never reach the profile's own
|
|
31
|
+
* region (see ARCHITECTURE.md region precedence).
|
|
32
|
+
*
|
|
33
|
+
* Unlike the endpoint and credential variables, a *blank* region is not "unset"
|
|
34
|
+
* for the SDK: an empty `AWS_REGION` makes the region provider throw
|
|
35
|
+
* (`Region not accepted: region="" is not a valid hostname component`). These
|
|
36
|
+
* keys are therefore blanked only as a last resort; {@link resolveRunRegion}
|
|
37
|
+
* fills in a real value whenever one can be found, and when none can be the app
|
|
38
|
+
* reports its clear "no region configured" error instead of silently using
|
|
39
|
+
* floci's region.
|
|
40
|
+
*/
|
|
41
|
+
export const REGION_KEYS = ['AWS_REGION', 'AWS_DEFAULT_REGION'];
|
|
42
|
+
/** Parses a TCP port, or returns `null` when the value is not a usable port. */
|
|
43
|
+
export function parsePort(value) {
|
|
44
|
+
const trimmed = value.trim();
|
|
45
|
+
if (!/^\d{1,5}$/.test(trimmed))
|
|
46
|
+
return null;
|
|
47
|
+
const port = Number(trimmed);
|
|
48
|
+
return port >= 1 && port <= 65535 ? port : null;
|
|
49
|
+
}
|
|
50
|
+
/** Inner text of every `[section]` header line, comments and blank lines skipped. */
|
|
51
|
+
function sectionHeaders(text) {
|
|
52
|
+
const headers = [];
|
|
53
|
+
for (const raw of text.split('\n')) {
|
|
54
|
+
const line = raw.trim();
|
|
55
|
+
if (line.length === 0 || line.startsWith('#') || line.startsWith(';'))
|
|
56
|
+
continue;
|
|
57
|
+
const match = /^\[([^\]]+)\]$/.exec(line);
|
|
58
|
+
if (match)
|
|
59
|
+
headers.push(match[1].trim());
|
|
60
|
+
}
|
|
61
|
+
return headers;
|
|
62
|
+
}
|
|
63
|
+
/** Deterministic ordering that does not depend on the platform locale. */
|
|
64
|
+
function compareNames(a, b) {
|
|
65
|
+
if (a === b)
|
|
66
|
+
return 0;
|
|
67
|
+
return a < b ? -1 : 1;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Profile names defined by a shared config file and a credentials file.
|
|
71
|
+
*
|
|
72
|
+
* Section parsing is deliberately simple: `[profile NAME]` and `[default]` in
|
|
73
|
+
* `~/.aws/config` (so `[sso-session x]` is ignored), `[NAME]` in
|
|
74
|
+
* `~/.aws/credentials`. The result is de-duplicated, `default` first, then
|
|
75
|
+
* sorted.
|
|
76
|
+
*/
|
|
77
|
+
export function collectProfiles(configText, credentialsText) {
|
|
78
|
+
const found = new Set();
|
|
79
|
+
for (const header of sectionHeaders(configText)) {
|
|
80
|
+
const named = /^profile\s+(.+)$/.exec(header);
|
|
81
|
+
if (named !== null)
|
|
82
|
+
found.add(named[1].trim());
|
|
83
|
+
else if (header === 'default')
|
|
84
|
+
found.add('default');
|
|
85
|
+
}
|
|
86
|
+
for (const header of sectionHeaders(credentialsText)) {
|
|
87
|
+
if (header.length > 0)
|
|
88
|
+
found.add(header);
|
|
89
|
+
}
|
|
90
|
+
const rest = [...found]
|
|
91
|
+
.filter((name) => name !== 'default' && name.length > 0)
|
|
92
|
+
.toSorted(compareNames);
|
|
93
|
+
return found.has('default') ? ['default', ...rest] : rest;
|
|
94
|
+
}
|
|
95
|
+
/** Reads a text file, returning an empty string when it is missing or unreadable. */
|
|
96
|
+
function readTextFile(path) {
|
|
97
|
+
try {
|
|
98
|
+
return readFileSync(path, 'utf8');
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return '';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Shared config and credentials file paths, honoring the standard overrides. */
|
|
105
|
+
export function profileFiles(env = process.env) {
|
|
106
|
+
const config = env.AWS_CONFIG_FILE?.trim();
|
|
107
|
+
const credentials = env.AWS_SHARED_CREDENTIALS_FILE?.trim();
|
|
108
|
+
return {
|
|
109
|
+
configFile: config !== undefined && config.length > 0 ? config : join(homedir(), '.aws', 'config'),
|
|
110
|
+
credentialsFile: credentials !== undefined && credentials.length > 0
|
|
111
|
+
? credentials
|
|
112
|
+
: join(homedir(), '.aws', 'credentials'),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** Profile names found in the AWS shared config and credentials files. */
|
|
116
|
+
export function readProfiles(env = process.env) {
|
|
117
|
+
const { configFile, credentialsFile } = profileFiles(env);
|
|
118
|
+
return collectProfiles(readTextFile(configFile), readTextFile(credentialsFile));
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* `region = ...` of one profile section in `~/.aws/config`, or `null` when that
|
|
122
|
+
* section is missing or has no region.
|
|
123
|
+
*/
|
|
124
|
+
export function readProfileRegion(configText, profile) {
|
|
125
|
+
const wanted = new Set(profile === 'default' ? ['default', 'profile default'] : [`profile ${profile}`]);
|
|
126
|
+
let section = null;
|
|
127
|
+
for (const raw of configText.split('\n')) {
|
|
128
|
+
const line = raw.trim();
|
|
129
|
+
if (line.length === 0 || line.startsWith('#') || line.startsWith(';'))
|
|
130
|
+
continue;
|
|
131
|
+
const header = /^\[([^\]]+)\]$/.exec(line);
|
|
132
|
+
if (header !== null) {
|
|
133
|
+
section = header[1].trim();
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (section === null || !wanted.has(section))
|
|
137
|
+
continue;
|
|
138
|
+
const pair = /^region\s*=\s*(.*)$/.exec(line);
|
|
139
|
+
if (pair === null)
|
|
140
|
+
continue;
|
|
141
|
+
const value = pair[1].replace(/\s[#;].*$/, '').trim();
|
|
142
|
+
if (value.length > 0)
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
/** Trims a value and treats blank strings as absent. */
|
|
148
|
+
function normalize(value) {
|
|
149
|
+
if (typeof value !== 'string')
|
|
150
|
+
return null;
|
|
151
|
+
const trimmed = value.trim();
|
|
152
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Region to hand to the child, mirroring the app's documented precedence:
|
|
156
|
+
* `--region`, then `AWS_REGION`/`AWS_DEFAULT_REGION` exported in the shell, then
|
|
157
|
+
* the region of the requested profile in `~/.aws/config`.
|
|
158
|
+
*
|
|
159
|
+
* Returns `null` when nothing can be resolved. The caller then blanks the region
|
|
160
|
+
* keys, and the app answers with "No AWS region is configured..." instead of
|
|
161
|
+
* silently keeping the region that `.env.local` pins for floci.
|
|
162
|
+
*/
|
|
163
|
+
export function resolveRunRegion({ region = null, profile = null, base, configText, }) {
|
|
164
|
+
const explicit = normalize(region);
|
|
165
|
+
if (explicit !== null)
|
|
166
|
+
return explicit;
|
|
167
|
+
const exported = normalize(base.AWS_REGION) ?? normalize(base.AWS_DEFAULT_REGION);
|
|
168
|
+
if (exported !== null)
|
|
169
|
+
return exported;
|
|
170
|
+
const name = normalize(profile);
|
|
171
|
+
if (name === null)
|
|
172
|
+
return null;
|
|
173
|
+
return readProfileRegion(configText, name);
|
|
174
|
+
}
|
|
175
|
+
/** Throwaway credentials local emulators accept; floci never checks them. */
|
|
176
|
+
const EMULATOR_CREDENTIALS = { accessKeyId: 'test', secretAccessKey: 'test' };
|
|
177
|
+
/**
|
|
178
|
+
* Builds the environment for the child process.
|
|
179
|
+
*
|
|
180
|
+
* Without `profile` the base environment is returned unchanged, so plain
|
|
181
|
+
* `pnpm dev` behaves exactly as before. With `profile` the local emulator
|
|
182
|
+
* settings (endpoint, static keys and the pinned region) are blanked and
|
|
183
|
+
* `AWS_PROFILE` is set, so the SDK resolves credentials and region from that
|
|
184
|
+
* profile; a non-blank `region` is then applied as `AWS_REGION` and
|
|
185
|
+
* `AWS_DEFAULT_REGION`, which is how `--region` (or a region resolved by
|
|
186
|
+
* {@link resolveRunRegion}) wins over the profile.
|
|
187
|
+
*/
|
|
188
|
+
export function buildChildEnv({ base, profile = null, region = null, endpoint = null, }) {
|
|
189
|
+
const env = { ...base };
|
|
190
|
+
const name = normalize(profile);
|
|
191
|
+
const emulator = normalize(endpoint);
|
|
192
|
+
if (name !== null) {
|
|
193
|
+
env.AWS_PROFILE = name;
|
|
194
|
+
for (const key of [...LOCAL_OVERRIDE_KEYS, ...REGION_KEYS])
|
|
195
|
+
env[key] = '';
|
|
196
|
+
}
|
|
197
|
+
if (emulator !== null) {
|
|
198
|
+
env.AWS_ENDPOINT_URL = emulator;
|
|
199
|
+
env.AWS_ENDPOINT_URL_LOGS = emulator;
|
|
200
|
+
// Local emulators accept any non-empty key pair. Never override keys the
|
|
201
|
+
// caller already configured: they may be signing for something real.
|
|
202
|
+
if (normalize(base.AWS_ACCESS_KEY_ID) === null) {
|
|
203
|
+
env.AWS_ACCESS_KEY_ID = EMULATOR_CREDENTIALS.accessKeyId;
|
|
204
|
+
env.AWS_SECRET_ACCESS_KEY = EMULATOR_CREDENTIALS.secretAccessKey;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const code = normalize(region);
|
|
208
|
+
if (code !== null) {
|
|
209
|
+
env.AWS_REGION = code;
|
|
210
|
+
env.AWS_DEFAULT_REGION = code;
|
|
211
|
+
}
|
|
212
|
+
return env;
|
|
213
|
+
}
|
|
214
|
+
/** True when the endpoint points at a local emulator rather than real AWS. */
|
|
215
|
+
export function isEmulatorEndpoint(endpoint) {
|
|
216
|
+
let hostname = '';
|
|
217
|
+
try {
|
|
218
|
+
hostname = new URL(endpoint).hostname.toLowerCase();
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
return (hostname === 'localhost' ||
|
|
224
|
+
hostname === '127.0.0.1' ||
|
|
225
|
+
hostname === '::1' ||
|
|
226
|
+
hostname.endsWith('.localhost') ||
|
|
227
|
+
hostname.includes('floci') ||
|
|
228
|
+
hostname.includes('localstack'));
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* `AWS_*` entries of a child environment as `KEY=value` lines, sorted by name.
|
|
232
|
+
*
|
|
233
|
+
* Blank values are kept: seeing `AWS_ENDPOINT_URL=` is how you confirm that the
|
|
234
|
+
* local emulator settings were neutralized for this run.
|
|
235
|
+
*/
|
|
236
|
+
export function describeChildEnv(env) {
|
|
237
|
+
return Object.keys(env)
|
|
238
|
+
.filter((key) => key.startsWith('AWS_'))
|
|
239
|
+
.toSorted(compareNames)
|
|
240
|
+
.map((key) => `${key}=${env[key] ?? ''}`);
|
|
241
|
+
}
|
|
242
|
+
/** Reads the shared AWS config file, or an empty string when it is missing. */
|
|
243
|
+
export function readConfigText(env = process.env) {
|
|
244
|
+
return readTextFile(profileFiles(env).configFile);
|
|
245
|
+
}
|
|
246
|
+
/** True when `.env.local` (the floci settings) exists in the repository root. */
|
|
247
|
+
export function isLocalEnvPresent(cwd = process.cwd()) {
|
|
248
|
+
return existsSync(join(cwd, LOCAL_ENV_FILE));
|
|
249
|
+
}
|
|
250
|
+
/** Exit code for a child killed by a signal, following the shell convention. */
|
|
251
|
+
export function signalExitCode(signal) {
|
|
252
|
+
switch (signal) {
|
|
253
|
+
case 'SIGINT':
|
|
254
|
+
return 130;
|
|
255
|
+
case 'SIGQUIT':
|
|
256
|
+
return 131;
|
|
257
|
+
case 'SIGTERM':
|
|
258
|
+
return 143;
|
|
259
|
+
case 'SIGHUP':
|
|
260
|
+
return 129;
|
|
261
|
+
default:
|
|
262
|
+
return 1;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Historic time-range helpers shared by the server and the browser.
|
|
3
|
+
*
|
|
4
|
+
* The preset list lives here so the API and the picker can never drift apart.
|
|
5
|
+
* Everything is pure; nothing here touches the DOM.
|
|
6
|
+
*/
|
|
7
|
+
/** Preset windows offered by the historic range picker. */
|
|
8
|
+
export const HISTORIC_PRESETS = ['15m', '1h', '3h', '12h', '24h', '5d'];
|
|
9
|
+
/** Human labels for the preset chips. */
|
|
10
|
+
export const PRESET_LABELS = {
|
|
11
|
+
'15m': '15 min',
|
|
12
|
+
'1h': '1 hour',
|
|
13
|
+
'3h': '3 hours',
|
|
14
|
+
'12h': '12 hours',
|
|
15
|
+
'24h': '24 hours',
|
|
16
|
+
'5d': '5 days',
|
|
17
|
+
};
|
|
18
|
+
const UNIT_MS = {
|
|
19
|
+
ms: 1,
|
|
20
|
+
s: 1000,
|
|
21
|
+
m: 60 * 1000,
|
|
22
|
+
h: 60 * 60 * 1000,
|
|
23
|
+
d: 24 * 60 * 60 * 1000,
|
|
24
|
+
w: 7 * 24 * 60 * 60 * 1000,
|
|
25
|
+
};
|
|
26
|
+
const DURATION_PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d|w)$/i;
|
|
27
|
+
/** True when the value is one of the offered presets. */
|
|
28
|
+
export function isHistoricPreset(value) {
|
|
29
|
+
return HISTORIC_PRESETS.includes(value);
|
|
30
|
+
}
|
|
31
|
+
/** Duration of a preset window in milliseconds. */
|
|
32
|
+
export function presetDurationMs(preset) {
|
|
33
|
+
const match = DURATION_PATTERN.exec(preset);
|
|
34
|
+
const amount = match === null ? 0 : Number(match[1]);
|
|
35
|
+
const unit = match === null ? 0 : (UNIT_MS[match[2].toLowerCase()] ?? 0);
|
|
36
|
+
return amount * unit;
|
|
37
|
+
}
|
|
38
|
+
/** Label for a preset, falling back to the raw value. */
|
|
39
|
+
export function presetLabel(preset) {
|
|
40
|
+
return isHistoricPreset(preset) ? PRESET_LABELS[preset] : preset;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Converts epoch milliseconds to the `YYYY-MM-DDTHH:mm` string a
|
|
44
|
+
* `datetime-local` input expects, in the browser's local time zone.
|
|
45
|
+
*/
|
|
46
|
+
/** Zero-pads a number for the `datetime-local` format. */
|
|
47
|
+
function pad(value) {
|
|
48
|
+
return String(value).padStart(2, '0');
|
|
49
|
+
}
|
|
50
|
+
export function toDateTimeLocal(epochMs) {
|
|
51
|
+
const date = new Date(epochMs);
|
|
52
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Converts a `datetime-local` value back to epoch milliseconds.
|
|
56
|
+
*
|
|
57
|
+
* The string is timezone-less, so it is interpreted in local time; `NaN` input
|
|
58
|
+
* returns `null`.
|
|
59
|
+
*/
|
|
60
|
+
export function fromDateTimeLocal(value) {
|
|
61
|
+
if (typeof value !== 'string' || value.trim() === '')
|
|
62
|
+
return null;
|
|
63
|
+
const parsed = Date.parse(value);
|
|
64
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
65
|
+
}
|
|
66
|
+
/** Formats a window bound for display, for example `14:05` or `10 May 14:05`. */
|
|
67
|
+
export function formatWindowBound(epochMs, now) {
|
|
68
|
+
const date = new Date(epochMs);
|
|
69
|
+
const time = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
|
70
|
+
const sameDay = new Date(now).toDateString() === date.toDateString();
|
|
71
|
+
if (sameDay)
|
|
72
|
+
return time;
|
|
73
|
+
const day = date.toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
|
|
74
|
+
return `${day} ${time}`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Describes the window reported by the server's `ready` event.
|
|
78
|
+
*
|
|
79
|
+
* Live tails report the lookback window; historic tails report the resolved
|
|
80
|
+
* range, naming the preset when one was used.
|
|
81
|
+
*/
|
|
82
|
+
export function describeWindow(ready, now) {
|
|
83
|
+
if (ready === null || ready.startTime === undefined) {
|
|
84
|
+
return { mode: 'live', label: 'live', title: 'Tailing new events', clamped: false };
|
|
85
|
+
}
|
|
86
|
+
const clamped = ready.clamped === true;
|
|
87
|
+
if (ready.mode !== 'historic' || ready.endTime === null || ready.endTime === undefined) {
|
|
88
|
+
return {
|
|
89
|
+
mode: 'live',
|
|
90
|
+
label: 'live',
|
|
91
|
+
title: `Live since ${formatWindowBound(ready.startTime, now)}`,
|
|
92
|
+
clamped,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const from = formatWindowBound(ready.startTime, now);
|
|
96
|
+
const to = formatWindowBound(ready.endTime, now);
|
|
97
|
+
const preset = ready.preset === null || ready.preset === undefined ? null : presetLabel(ready.preset);
|
|
98
|
+
return {
|
|
99
|
+
mode: 'historic',
|
|
100
|
+
label: preset === null ? `${from} → ${to}` : `${preset} · ${from} → ${to}`,
|
|
101
|
+
title: clamped
|
|
102
|
+
? 'Historic window, clamped to the last 14 days that CloudWatch Logs keeps'
|
|
103
|
+
: 'Historic window',
|
|
104
|
+
clamped,
|
|
105
|
+
};
|
|
106
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "watch-tail",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Tail Amazon CloudWatch Logs in your browser: a local SvelteKit UI with live and historic streaming, ambient AWS credentials and floci support",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"aws",
|
|
7
|
+
"cli",
|
|
8
|
+
"cloudwatch",
|
|
9
|
+
"cloudwatch-logs",
|
|
10
|
+
"floci",
|
|
11
|
+
"localstack",
|
|
12
|
+
"log-viewer",
|
|
13
|
+
"logs",
|
|
14
|
+
"sveltekit",
|
|
15
|
+
"tail",
|
|
16
|
+
"tailwindcss"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://github.com/antstanley/watch-stream#readme",
|
|
19
|
+
"bugs": "https://github.com/antstanley/watch-stream/issues",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/antstanley/watch-stream.git"
|
|
24
|
+
},
|
|
25
|
+
"bin": {
|
|
26
|
+
"watch-tail": "./dist/cli/bin.js"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"build",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"type": "module",
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"dev": "vite dev",
|
|
40
|
+
"build": "vite build && tsc -p tsconfig.cli.json",
|
|
41
|
+
"preview": "vite preview",
|
|
42
|
+
"start": "node dist/cli/bin.js",
|
|
43
|
+
"prepare": "svelte-kit sync || echo ''",
|
|
44
|
+
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
45
|
+
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
46
|
+
"test": "vitest run",
|
|
47
|
+
"test:watch": "vitest",
|
|
48
|
+
"test:unit": "vitest run",
|
|
49
|
+
"lint": "oxlint",
|
|
50
|
+
"lint:fix": "oxlint --fix",
|
|
51
|
+
"format": "oxfmt --write .",
|
|
52
|
+
"format:check": "oxfmt --check .",
|
|
53
|
+
"knip": "knip",
|
|
54
|
+
"verify": "pnpm check && pnpm lint && pnpm format:check && pnpm test && pnpm knip && pnpm build",
|
|
55
|
+
"floci:start": "floci start",
|
|
56
|
+
"floci:stop": "floci stop",
|
|
57
|
+
"floci:env": "node scripts/floci-env.ts",
|
|
58
|
+
"floci:up": "floci start && node scripts/floci-env.ts",
|
|
59
|
+
"seed": "node scripts/seed-floci.ts",
|
|
60
|
+
"seed:watch": "node scripts/seed-floci.ts --watch",
|
|
61
|
+
"test:e2e": "WATCH_STREAM_E2E=1 vitest run --project integration",
|
|
62
|
+
"dev:aws": "node scripts/dev.ts",
|
|
63
|
+
"start:aws": "node scripts/dev.ts --start",
|
|
64
|
+
"test:ui": "node scripts/ui-smoke.ts",
|
|
65
|
+
"build:app": "vite build",
|
|
66
|
+
"build:cli": "tsc -p tsconfig.cli.json",
|
|
67
|
+
"prepack": "pnpm build",
|
|
68
|
+
"watch-tail": "node dist/cli/bin.js",
|
|
69
|
+
"publish:check": "publint && npm pack --dry-run",
|
|
70
|
+
"test:cli": "WATCH_TAIL_E2E=1 vitest run --project integration tests/cli-server.spec.ts",
|
|
71
|
+
"changeset": "changeset",
|
|
72
|
+
"changeset:version": "changeset version && pnpm install --no-frozen-lockfile"
|
|
73
|
+
},
|
|
74
|
+
"dependencies": {
|
|
75
|
+
"@aws-sdk/client-cloudwatch-logs": "^3.1129.0",
|
|
76
|
+
"@bomb.sh/args": "^0.3.1",
|
|
77
|
+
"@bomb.sh/tab": "^0.0.22",
|
|
78
|
+
"@clack/prompts": "^1.8.0"
|
|
79
|
+
},
|
|
80
|
+
"devDependencies": {
|
|
81
|
+
"@changesets/changelog-github": "^1.0.1",
|
|
82
|
+
"@changesets/cli": "^3.0.2",
|
|
83
|
+
"@sveltejs/adapter-node": "^5.5.4",
|
|
84
|
+
"@sveltejs/kit": "^2.63.0",
|
|
85
|
+
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
|
86
|
+
"@tailwindcss/vite": "^4.3.0",
|
|
87
|
+
"@testing-library/svelte": "^5.4.2",
|
|
88
|
+
"@types/node": "^22.20.2",
|
|
89
|
+
"jsdom": "^30.0.1",
|
|
90
|
+
"knip": "^6.35.1",
|
|
91
|
+
"oxfmt": "^0.67.0",
|
|
92
|
+
"oxlint": "^1.82.0",
|
|
93
|
+
"playwright": "^1.63.0",
|
|
94
|
+
"publint": "^0.3.24",
|
|
95
|
+
"svelte": "^5.56.1",
|
|
96
|
+
"svelte-check": "^4.6.0",
|
|
97
|
+
"tailwindcss": "^4.3.0",
|
|
98
|
+
"typescript": "^6.0.3",
|
|
99
|
+
"vite": "^8.0.16",
|
|
100
|
+
"vitest": "^4.1.8"
|
|
101
|
+
},
|
|
102
|
+
"engines": {
|
|
103
|
+
"node": ">=22"
|
|
104
|
+
},
|
|
105
|
+
"packageManager": "pnpm@11.24.0"
|
|
106
|
+
}
|