naro-cli 0.13.0 → 0.14.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/README.md +38 -1
- package/dist/{chunk-O4NJRDJE.js → chunk-U7AZSAFS.js} +58 -13
- package/dist/deploy.js +1 -1
- package/dist/index.js +453 -301
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -69,9 +69,13 @@ naro logs <deployment-id> Build logs of a deployment
|
|
|
69
69
|
naro logs --type build --follow Stream build logs until READY/FAILED
|
|
70
70
|
naro logs --env preview Preview-surface runtime logs
|
|
71
71
|
|
|
72
|
-
naro
|
|
72
|
+
naro analytics Web Analytics: visitors, page views, top pages, referrers, countries, devices, browsers, operating systems (--range 7d)
|
|
73
|
+
naro analytics --country KR --os ios Narrow it: --path, --referrer, --country, --device, --browser, --os (one value each, all must match)
|
|
74
|
+
|
|
75
|
+
naro insights Speed Insights: Web Vitals p75, and the page load's p75 and the mean of each step (--metric lcp, --range 7d)
|
|
73
76
|
naro insights --route '/posts/[id]' The paths under one route
|
|
74
77
|
naro insights --env preview The same for the preview worker
|
|
78
|
+
naro insights --country KR --device mobile Narrow it: --path, --country, --device, --browser, --os (no --referrer: Speed Insights has none)
|
|
75
79
|
naro insights enable | disable Turn measuring on or off
|
|
76
80
|
|
|
77
81
|
naro protection Preview protection: on or off, and whether a bypass secret is set
|
|
@@ -789,6 +793,39 @@ An `--update-check` you give replaces the default: without
|
|
|
789
793
|
|
|
790
794
|
## Release notes
|
|
791
795
|
|
|
796
|
+
### 0.14.0
|
|
797
|
+
|
|
798
|
+
- `naro analytics` shows the project's Web Analytics from the terminal: the
|
|
799
|
+
range's visitors and page views (`--range 24h|7d|30d`, 7d by default), then
|
|
800
|
+
the top pages, referrers, countries, devices, browsers and operating systems
|
|
801
|
+
by page views, and `--json` prints the whole report, trend included. The
|
|
802
|
+
same six filters as on the dashboard narrow it, one value each, all of them
|
|
803
|
+
at once: `naro analytics --country KR --os ios`. The first line names the
|
|
804
|
+
filters that applied; filters that leave no visit print one line under it,
|
|
805
|
+
`No visits match these filters. Drop a filter to see more.`, instead of six
|
|
806
|
+
empty tables. The CLI does not check the values; the server answers
|
|
807
|
+
`400 VALIDATION_FAILED` with the rule the value broke (`--country` is an
|
|
808
|
+
uppercase two-letter code like `KR`, `--referrer` a lowercase host like
|
|
809
|
+
`t.co`; `kr` and `T.co` are refused). It reads the
|
|
810
|
+
production worker only, so `--env` is refused with `VALIDATION_FAILED` and
|
|
811
|
+
nothing is sent. An API token (`--token`, `NARO_TOKEN`) can read it.
|
|
812
|
+
- `naro insights` takes the same filters: `--path`, `--country`, `--device`,
|
|
813
|
+
`--browser` and `--os` narrow it to one value each (`--referrer` is refused
|
|
814
|
+
with `VALIDATION_FAILED` and nothing is sent: a vitals row has no referrer).
|
|
815
|
+
The first line names the filters that applied
|
|
816
|
+
(`· filtered by country=KR, os=ios`); filters that leave no measurement print
|
|
817
|
+
`No measurements match these filters. Drop a filter to see more.` instead of
|
|
818
|
+
waiting advice.
|
|
819
|
+
The filters narrow a report only: `naro insights enable | disable` refuses
|
|
820
|
+
them (`Filters only narrow a report; drop --country.`) and sends nothing, and
|
|
821
|
+
an empty value (`--referrer=`) is no filter.
|
|
822
|
+
- `naro insights` prints a `Page load p75 … · mean per step: …` line under the
|
|
823
|
+
metrics table: the p75 of how long visits took to load the page, to the end
|
|
824
|
+
of the load event, then the mean of each step (wait, DNS, connect, server,
|
|
825
|
+
download, processing, onload), so the steps need not add up to the p75. It is
|
|
826
|
+
left out until a visit has sent a page load. The dashboard's Speed Insights
|
|
827
|
+
tab has the same Page load card.
|
|
828
|
+
|
|
792
829
|
### 0.13.0
|
|
793
830
|
|
|
794
831
|
- `naro insights` shows the project's Speed Insights from the terminal: each
|
|
@@ -18488,6 +18488,16 @@ function createLogSender(ports) {
|
|
|
18488
18488
|
};
|
|
18489
18489
|
}
|
|
18490
18490
|
|
|
18491
|
+
// ../../packages/sdk/src/analytics.ts
|
|
18492
|
+
var ANALYTICS_FILTER_KEYS = [
|
|
18493
|
+
"path",
|
|
18494
|
+
"referrer",
|
|
18495
|
+
"country",
|
|
18496
|
+
"device",
|
|
18497
|
+
"browser",
|
|
18498
|
+
"os"
|
|
18499
|
+
];
|
|
18500
|
+
|
|
18491
18501
|
// ../../packages/sdk/src/auth.ts
|
|
18492
18502
|
import { homedir as homedir2 } from "os";
|
|
18493
18503
|
|
|
@@ -18960,6 +18970,23 @@ function openBrowser(url2) {
|
|
|
18960
18970
|
child.unref();
|
|
18961
18971
|
}
|
|
18962
18972
|
|
|
18973
|
+
// ../../packages/sdk/src/insights.ts
|
|
18974
|
+
var SPEED_INSIGHTS_METRICS = [
|
|
18975
|
+
"lcp",
|
|
18976
|
+
"inp",
|
|
18977
|
+
"cls",
|
|
18978
|
+
"fcp",
|
|
18979
|
+
"ttfb"
|
|
18980
|
+
];
|
|
18981
|
+
var SPEED_INSIGHTS_FILTER_KEYS = [
|
|
18982
|
+
"path",
|
|
18983
|
+
"country",
|
|
18984
|
+
"device",
|
|
18985
|
+
"browser",
|
|
18986
|
+
"os"
|
|
18987
|
+
];
|
|
18988
|
+
var SPEED_INSIGHTS_MIN_DEPLOYMENT_SAMPLES = 20;
|
|
18989
|
+
|
|
18963
18990
|
// ../../packages/sdk/src/types.ts
|
|
18964
18991
|
var TERMINAL_DEPLOYMENT_STATUSES = [
|
|
18965
18992
|
"READY",
|
|
@@ -19052,6 +19079,7 @@ var NaroClient = class {
|
|
|
19052
19079
|
backend;
|
|
19053
19080
|
storage;
|
|
19054
19081
|
insights;
|
|
19082
|
+
analytics;
|
|
19055
19083
|
protection;
|
|
19056
19084
|
constructor(options) {
|
|
19057
19085
|
this.baseUrl = (options.baseUrl ?? controlPlaneUrl()).replace(/\/$/, "");
|
|
@@ -19069,6 +19097,7 @@ var NaroClient = class {
|
|
|
19069
19097
|
this.backend = new BackendApi(this);
|
|
19070
19098
|
this.storage = new StorageApi(this);
|
|
19071
19099
|
this.insights = new InsightsApi(this);
|
|
19100
|
+
this.analytics = new AnalyticsApi(this);
|
|
19072
19101
|
this.protection = new ProtectionApi(this);
|
|
19073
19102
|
}
|
|
19074
19103
|
async request(path, options) {
|
|
@@ -19477,12 +19506,20 @@ var LogsApi = class {
|
|
|
19477
19506
|
return body.entries;
|
|
19478
19507
|
}
|
|
19479
19508
|
};
|
|
19509
|
+
function appendFilters(params, keys, filters) {
|
|
19510
|
+
if (!filters) return;
|
|
19511
|
+
for (const key of keys) {
|
|
19512
|
+
const value = filters[key];
|
|
19513
|
+
if (value) params.set(key, value);
|
|
19514
|
+
}
|
|
19515
|
+
}
|
|
19480
19516
|
function insightsQuery(options) {
|
|
19481
19517
|
const params = new URLSearchParams();
|
|
19482
19518
|
if (options.range !== void 0) params.set("range", options.range);
|
|
19483
19519
|
if (options.metric !== void 0) params.set("metric", options.metric);
|
|
19484
19520
|
if (options.environment !== void 0) params.set("env", options.environment);
|
|
19485
19521
|
if (options.route !== void 0) params.set("route", options.route);
|
|
19522
|
+
appendFilters(params, SPEED_INSIGHTS_FILTER_KEYS, options.filters);
|
|
19486
19523
|
const text2 = params.toString();
|
|
19487
19524
|
return text2 ? `?${text2}` : "";
|
|
19488
19525
|
}
|
|
@@ -19493,7 +19530,7 @@ var InsightsApi = class {
|
|
|
19493
19530
|
base(projectId) {
|
|
19494
19531
|
return `/api/projects/${encodeURIComponent(projectId)}/speed-insights`;
|
|
19495
19532
|
}
|
|
19496
|
-
/** The five metrics' summary, and the trend, pages, devices and elements of one. */
|
|
19533
|
+
/** The five metrics' summary, and the trend, pages, devices, browsers, operating systems, countries and elements of one. */
|
|
19497
19534
|
async report(projectId, options = {}) {
|
|
19498
19535
|
return this.client.request(
|
|
19499
19536
|
`${this.base(projectId)}${insightsQuery(options)}`,
|
|
@@ -19553,6 +19590,22 @@ var InsightsApi = class {
|
|
|
19553
19590
|
});
|
|
19554
19591
|
}
|
|
19555
19592
|
};
|
|
19593
|
+
var AnalyticsApi = class {
|
|
19594
|
+
constructor(client) {
|
|
19595
|
+
this.client = client;
|
|
19596
|
+
}
|
|
19597
|
+
/** Visitors, page views, the trend, and the pages, referrers, countries, devices, browsers and operating systems behind them. */
|
|
19598
|
+
async report(projectId, options = {}) {
|
|
19599
|
+
const params = new URLSearchParams();
|
|
19600
|
+
if (options.range !== void 0) params.set("range", options.range);
|
|
19601
|
+
appendFilters(params, ANALYTICS_FILTER_KEYS, options.filters);
|
|
19602
|
+
const text2 = params.toString();
|
|
19603
|
+
return this.client.request(
|
|
19604
|
+
`/api/projects/${encodeURIComponent(projectId)}/analytics${text2 ? `?${text2}` : ""}`,
|
|
19605
|
+
{ context: "Read Analytics" }
|
|
19606
|
+
);
|
|
19607
|
+
}
|
|
19608
|
+
};
|
|
19556
19609
|
var ProtectionApi = class {
|
|
19557
19610
|
constructor(client) {
|
|
19558
19611
|
this.client = client;
|
|
@@ -27333,16 +27386,6 @@ function generateTypes(tables, options = {}) {
|
|
|
27333
27386
|
`;
|
|
27334
27387
|
}
|
|
27335
27388
|
|
|
27336
|
-
// ../../packages/sdk/src/insights.ts
|
|
27337
|
-
var SPEED_INSIGHTS_METRICS = [
|
|
27338
|
-
"lcp",
|
|
27339
|
-
"inp",
|
|
27340
|
-
"cls",
|
|
27341
|
-
"fcp",
|
|
27342
|
-
"ttfb"
|
|
27343
|
-
];
|
|
27344
|
-
var SPEED_INSIGHTS_MIN_DEPLOYMENT_SAMPLES = 20;
|
|
27345
|
-
|
|
27346
27389
|
// ../../packages/sdk/src/link.ts
|
|
27347
27390
|
import {
|
|
27348
27391
|
existsSync as existsSync4,
|
|
@@ -28464,6 +28507,7 @@ export {
|
|
|
28464
28507
|
schemaMigrationTitle,
|
|
28465
28508
|
projectPath,
|
|
28466
28509
|
splitStatements,
|
|
28510
|
+
ANALYTICS_FILTER_KEYS,
|
|
28467
28511
|
NaroError,
|
|
28468
28512
|
isNaroError,
|
|
28469
28513
|
toNaroError,
|
|
@@ -28477,6 +28521,9 @@ export {
|
|
|
28477
28521
|
login,
|
|
28478
28522
|
resolveAuth,
|
|
28479
28523
|
openBrowser,
|
|
28524
|
+
SPEED_INSIGHTS_METRICS,
|
|
28525
|
+
SPEED_INSIGHTS_FILTER_KEYS,
|
|
28526
|
+
SPEED_INSIGHTS_MIN_DEPLOYMENT_SAMPLES,
|
|
28480
28527
|
isTerminalStatus,
|
|
28481
28528
|
BACKEND_STEPS,
|
|
28482
28529
|
isStalledBackend,
|
|
@@ -28515,8 +28562,6 @@ export {
|
|
|
28515
28562
|
formatRedirectEntry,
|
|
28516
28563
|
policiesForTypes,
|
|
28517
28564
|
generateTypes,
|
|
28518
|
-
SPEED_INSIGHTS_METRICS,
|
|
28519
|
-
SPEED_INSIGHTS_MIN_DEPLOYMENT_SAMPLES,
|
|
28520
28565
|
readProjectLink,
|
|
28521
28566
|
writeProjectLink,
|
|
28522
28567
|
removeProjectLink,
|
package/dist/deploy.js
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
+
ANALYTICS_FILTER_KEYS,
|
|
3
4
|
BACKEND_STEPS,
|
|
4
5
|
BOOKMARK_FORMAT_MESSAGE,
|
|
5
6
|
INDEX_SQL,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
RESET_BACKEND_OUTDATED,
|
|
14
15
|
RESTORE_OUTCOME_UNKNOWN_CODES,
|
|
15
16
|
SCHEMA_SQL,
|
|
17
|
+
SPEED_INSIGHTS_FILTER_KEYS,
|
|
16
18
|
SPEED_INSIGHTS_METRICS,
|
|
17
19
|
SPEED_INSIGHTS_MIN_DEPLOYMENT_SAMPLES,
|
|
18
20
|
STORAGE_OBJECTS_TABLE,
|
|
@@ -81,7 +83,7 @@ import {
|
|
|
81
83
|
toNaroError,
|
|
82
84
|
unreadablePolicyComment,
|
|
83
85
|
writeProjectLink
|
|
84
|
-
} from "./chunk-
|
|
86
|
+
} from "./chunk-U7AZSAFS.js";
|
|
85
87
|
import "./chunk-K3NQKI34.js";
|
|
86
88
|
|
|
87
89
|
// src/args.ts
|
|
@@ -139,6 +141,12 @@ var STRING_FLAGS = {
|
|
|
139
141
|
"--metric": "metric",
|
|
140
142
|
"--range": "range",
|
|
141
143
|
"--route": "route",
|
|
144
|
+
"--path": "path",
|
|
145
|
+
"--referrer": "referrer",
|
|
146
|
+
"--country": "country",
|
|
147
|
+
"--device": "device",
|
|
148
|
+
"--browser": "browser",
|
|
149
|
+
"--os": "os",
|
|
142
150
|
"--file": "file",
|
|
143
151
|
"--output": "output",
|
|
144
152
|
"--lang": "lang",
|
|
@@ -490,86 +498,9 @@ import { readFileSync as readFileSync5 } from "fs";
|
|
|
490
498
|
import { dirname as dirname4, join as join7 } from "path";
|
|
491
499
|
import { fileURLToPath } from "url";
|
|
492
500
|
|
|
493
|
-
// src/commands/
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
output.log("Opening your browser to sign in with GitHub...");
|
|
497
|
-
const session = await login({
|
|
498
|
-
openBrowser: (url) => {
|
|
499
|
-
output.log(`If your browser did not open, visit:
|
|
500
|
-
${url}`);
|
|
501
|
-
openBrowser(url);
|
|
502
|
-
}
|
|
503
|
-
});
|
|
504
|
-
saveSession(session);
|
|
505
|
-
output.result(
|
|
506
|
-
{
|
|
507
|
-
user: {
|
|
508
|
-
id: session.user.id,
|
|
509
|
-
email: session.user.email,
|
|
510
|
-
github: session.user.github
|
|
511
|
-
}
|
|
512
|
-
},
|
|
513
|
-
[`\u2705 Logged in as ${session.user.email ?? session.user.id}`]
|
|
514
|
-
);
|
|
515
|
-
}
|
|
516
|
-
async function runLogout(context) {
|
|
517
|
-
const session = loadStoredSession();
|
|
518
|
-
if (session) {
|
|
519
|
-
try {
|
|
520
|
-
const client = createAuthClient();
|
|
521
|
-
await client.auth.setSession({
|
|
522
|
-
access_token: session.access_token,
|
|
523
|
-
refresh_token: session.refresh_token
|
|
524
|
-
});
|
|
525
|
-
await client.auth.signOut();
|
|
526
|
-
} catch {
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
clearSession();
|
|
530
|
-
context.output.result({ loggedOut: true }, ["Logged out."]);
|
|
531
|
-
}
|
|
532
|
-
async function currentSession() {
|
|
533
|
-
let session = loadStoredSession();
|
|
534
|
-
if (!session)
|
|
535
|
-
throw new NaroError("NOT_LOGGED_IN", "Not logged in. Run `naro login`.");
|
|
536
|
-
if (isExpired(session)) {
|
|
537
|
-
try {
|
|
538
|
-
session = await refreshStoredSession(session.refresh_token);
|
|
539
|
-
saveSession(session);
|
|
540
|
-
} catch {
|
|
541
|
-
throw new NaroError(
|
|
542
|
-
"NOT_LOGGED_IN",
|
|
543
|
-
"Session expired. Run `naro login`."
|
|
544
|
-
);
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
return session;
|
|
548
|
-
}
|
|
549
|
-
async function runWhoami(context) {
|
|
550
|
-
const session = await currentSession();
|
|
551
|
-
const handle = session.user.github ? ` (${session.user.github})` : "";
|
|
552
|
-
context.output.result(
|
|
553
|
-
{
|
|
554
|
-
user: {
|
|
555
|
-
id: session.user.id,
|
|
556
|
-
email: session.user.email,
|
|
557
|
-
github: session.user.github
|
|
558
|
-
}
|
|
559
|
-
},
|
|
560
|
-
[`Logged in as ${session.user.email ?? session.user.id}${handle}`]
|
|
561
|
-
);
|
|
562
|
-
}
|
|
563
|
-
async function runToken(context) {
|
|
564
|
-
const session = await currentSession();
|
|
565
|
-
process.stderr.write(
|
|
566
|
-
"\u26A0 Access tokens expire after about an hour \u2014 generate one per CI run.\n"
|
|
567
|
-
);
|
|
568
|
-
context.output.result(
|
|
569
|
-
{ token: session.access_token, expiresAt: session.expires_at },
|
|
570
|
-
[session.access_token]
|
|
571
|
-
);
|
|
572
|
-
}
|
|
501
|
+
// src/commands/migration.ts
|
|
502
|
+
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
503
|
+
import { join, relative } from "path";
|
|
573
504
|
|
|
574
505
|
// src/commands/confirm.ts
|
|
575
506
|
function cannotAsk(context) {
|
|
@@ -588,8 +519,6 @@ async function confirmOrAbort(context, question) {
|
|
|
588
519
|
}
|
|
589
520
|
|
|
590
521
|
// src/commands/migration.ts
|
|
591
|
-
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
592
|
-
import { join, relative } from "path";
|
|
593
522
|
var processStdin = {
|
|
594
523
|
stdinIsTty: () => process.stdin.isTTY === true,
|
|
595
524
|
readStdin
|
|
@@ -792,34 +721,409 @@ async function runMigrationUp(context) {
|
|
|
792
721
|
]);
|
|
793
722
|
return;
|
|
794
723
|
}
|
|
795
|
-
if (context.flags.dryRun) {
|
|
796
|
-
const problems = policyProblems(dir, pending);
|
|
797
|
-
context.output.result({ projectId, dryRun: true, pending, problems }, [
|
|
798
|
-
`Pending migrations (${String(pending.length)}):`,
|
|
799
|
-
...pending.map((name) => ` ${printable(name)}`),
|
|
800
|
-
...problems.map(
|
|
801
|
-
(problem) => `${printable(problem.migration)}: ${printable(problem.message)}`
|
|
802
|
-
)
|
|
803
|
-
]);
|
|
804
|
-
if (problems.length > 0) process.exitCode = 1;
|
|
805
|
-
return;
|
|
724
|
+
if (context.flags.dryRun) {
|
|
725
|
+
const problems = policyProblems(dir, pending);
|
|
726
|
+
context.output.result({ projectId, dryRun: true, pending, problems }, [
|
|
727
|
+
`Pending migrations (${String(pending.length)}):`,
|
|
728
|
+
...pending.map((name) => ` ${printable(name)}`),
|
|
729
|
+
...problems.map(
|
|
730
|
+
(problem) => `${printable(problem.migration)}: ${printable(problem.message)}`
|
|
731
|
+
)
|
|
732
|
+
]);
|
|
733
|
+
if (problems.length > 0) process.exitCode = 1;
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
context.output.log(`Pending migrations (${String(pending.length)}):`);
|
|
737
|
+
for (const name of pending) context.output.log(` ${printable(name)}`);
|
|
738
|
+
await confirmOrAbort(
|
|
739
|
+
context,
|
|
740
|
+
`Apply ${String(pending.length)} migration(s) to the project database? Each file is applied all-or-nothing; a failure stops the run.`
|
|
741
|
+
);
|
|
742
|
+
const { applied, warnings } = await applyMigrationFiles(
|
|
743
|
+
context,
|
|
744
|
+
client,
|
|
745
|
+
projectId,
|
|
746
|
+
dir,
|
|
747
|
+
pending
|
|
748
|
+
);
|
|
749
|
+
context.output.result({ projectId, applied, pending: [], warnings }, [
|
|
750
|
+
`\u2705 Applied ${String(applied.length)} migration(s).`
|
|
751
|
+
]);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// src/commands/filters.ts
|
|
755
|
+
function filtersOf(flags, keys) {
|
|
756
|
+
const filters = {};
|
|
757
|
+
for (const key of keys) {
|
|
758
|
+
const value = flags[key];
|
|
759
|
+
if (value !== void 0) filters[key] = value;
|
|
760
|
+
}
|
|
761
|
+
return filters;
|
|
762
|
+
}
|
|
763
|
+
function givenFilterFlag(flags) {
|
|
764
|
+
return ANALYTICS_FILTER_KEYS.find((key) => {
|
|
765
|
+
const value = flags[key];
|
|
766
|
+
return value !== void 0 && value !== "";
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
function filteredBy(filters) {
|
|
770
|
+
const applied = ANALYTICS_FILTER_KEYS.flatMap((key) => {
|
|
771
|
+
const value = filters[key];
|
|
772
|
+
return value ? [`${key}=${printable(value)}`] : [];
|
|
773
|
+
});
|
|
774
|
+
return applied.length === 0 ? "" : ` \xB7 filtered by ${applied.join(", ")}`;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// src/commands/insights-format.ts
|
|
778
|
+
var OFF_LINE = "Speed Insights is off for this project. Turn it on: naro insights enable";
|
|
779
|
+
var TURNED_ON = "Speed Insights is on. Measuring starts within about a minute; values arrive when visitors leave a page.";
|
|
780
|
+
var TURNED_OFF = "Speed Insights is off. Measuring stops within about a minute; turning it back on shows past measurements again.";
|
|
781
|
+
var RANGE_TEXT = {
|
|
782
|
+
"24h": "last 24 hours",
|
|
783
|
+
"7d": "last 7 days",
|
|
784
|
+
"30d": "last 30 days"
|
|
785
|
+
};
|
|
786
|
+
var PART_TEXT = {
|
|
787
|
+
ttfb: "TTFB",
|
|
788
|
+
loadDelay: "load delay",
|
|
789
|
+
loadDuration: "load duration",
|
|
790
|
+
renderDelay: "render delay",
|
|
791
|
+
inputDelay: "input delay",
|
|
792
|
+
processing: "processing",
|
|
793
|
+
presentation: "presentation"
|
|
794
|
+
};
|
|
795
|
+
var PAGE_LOAD_PART_TEXT = {
|
|
796
|
+
wait: "wait",
|
|
797
|
+
dns: "DNS",
|
|
798
|
+
connect: "connect",
|
|
799
|
+
server: "server",
|
|
800
|
+
download: "download",
|
|
801
|
+
processing: "processing",
|
|
802
|
+
onload: "onload"
|
|
803
|
+
};
|
|
804
|
+
function rangeText(range) {
|
|
805
|
+
const known = RANGE_TEXT;
|
|
806
|
+
return known[range] ?? printable(range);
|
|
807
|
+
}
|
|
808
|
+
function partText(name) {
|
|
809
|
+
const known = PART_TEXT;
|
|
810
|
+
return known[name] ?? printable(name);
|
|
811
|
+
}
|
|
812
|
+
function pageLoadPartText(name) {
|
|
813
|
+
const known = PAGE_LOAD_PART_TEXT;
|
|
814
|
+
return known[name] ?? printable(name);
|
|
815
|
+
}
|
|
816
|
+
var ATTRIBUTED = /* @__PURE__ */ new Set([
|
|
817
|
+
"lcp",
|
|
818
|
+
"inp",
|
|
819
|
+
"cls"
|
|
820
|
+
]);
|
|
821
|
+
var DEPLOYMENT_ROWS = 10;
|
|
822
|
+
var SUBJECT_LENGTH = 50;
|
|
823
|
+
function formatMs(ms) {
|
|
824
|
+
const rounded = Math.round(ms);
|
|
825
|
+
return rounded < 1e3 ? `${String(rounded)} ms` : `${(rounded / 1e3).toFixed(2)} s`;
|
|
826
|
+
}
|
|
827
|
+
function formatValue(metric, value) {
|
|
828
|
+
return metric === "cls" ? value.toFixed(2) : formatMs(value);
|
|
829
|
+
}
|
|
830
|
+
function formatChange(metric, change2) {
|
|
831
|
+
const size = formatValue(metric, Math.abs(change2));
|
|
832
|
+
if (Number.parseFloat(size) === 0) return "\xB10";
|
|
833
|
+
return change2 > 0 ? `\u25B2 +${size}` : `\u25BC \u2212${size}`;
|
|
834
|
+
}
|
|
835
|
+
function formatCount(count2) {
|
|
836
|
+
return Math.round(count2).toLocaleString("en-US");
|
|
837
|
+
}
|
|
838
|
+
function percent(part, total) {
|
|
839
|
+
return `${String(Math.round(part / total * 100))}%`;
|
|
840
|
+
}
|
|
841
|
+
function label(metric) {
|
|
842
|
+
return metric.toUpperCase();
|
|
843
|
+
}
|
|
844
|
+
function environmentSuffix(environment) {
|
|
845
|
+
return environment === "preview" ? ", preview" : "";
|
|
846
|
+
}
|
|
847
|
+
function environmentWord(environment) {
|
|
848
|
+
return environment === "preview" ? "preview " : "";
|
|
849
|
+
}
|
|
850
|
+
function table(rows) {
|
|
851
|
+
const widths = [];
|
|
852
|
+
for (const row of rows)
|
|
853
|
+
row.forEach((cell, i) => {
|
|
854
|
+
widths[i] = Math.max(widths[i] ?? 0, cell.length);
|
|
855
|
+
});
|
|
856
|
+
return rows.map(
|
|
857
|
+
(row) => ` ${row.map((cell, i) => cell.padEnd(widths[i] ?? 0)).join(" ")}`.trimEnd()
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
function summaryLines(report) {
|
|
861
|
+
return table([
|
|
862
|
+
["Metric", "p75", "Good", "Needs improvement", "Poor", "Visits"],
|
|
863
|
+
...SPEED_INSIGHTS_METRICS.map((metric) => {
|
|
864
|
+
const summary = report.summary[metric];
|
|
865
|
+
if (summary.p75 === null || summary.samples <= 0)
|
|
866
|
+
return [label(metric), "\u2014", "\u2014", "\u2014", "\u2014", "0"];
|
|
867
|
+
return [
|
|
868
|
+
label(metric),
|
|
869
|
+
formatValue(metric, summary.p75),
|
|
870
|
+
percent(summary.good, summary.samples),
|
|
871
|
+
percent(summary.needsImprovement, summary.samples),
|
|
872
|
+
percent(summary.poor, summary.samples),
|
|
873
|
+
formatCount(summary.samples)
|
|
874
|
+
];
|
|
875
|
+
})
|
|
876
|
+
]);
|
|
877
|
+
}
|
|
878
|
+
function pageLoadLine(pageLoad) {
|
|
879
|
+
if (!pageLoad || pageLoad.samples <= 0 || pageLoad.p75 === null) return null;
|
|
880
|
+
const head = `Page load p75 ${formatMs(pageLoad.p75)}`;
|
|
881
|
+
if (pageLoad.parts.length === 0) return head;
|
|
882
|
+
const steps = pageLoad.parts.map(
|
|
883
|
+
(part) => `${pageLoadPartText(part.name)} ${formatMs(part.ms)}`
|
|
884
|
+
);
|
|
885
|
+
return `${head} \xB7 mean per step: ${steps.join(" \xB7 ")}`;
|
|
886
|
+
}
|
|
887
|
+
function breakdownTable(heading, metric, rows) {
|
|
888
|
+
if (rows.length === 0) return [" (none)"];
|
|
889
|
+
return table([
|
|
890
|
+
["p75", "Visits", heading],
|
|
891
|
+
...rows.map((row) => [
|
|
892
|
+
formatValue(metric, row.p75),
|
|
893
|
+
formatCount(row.samples),
|
|
894
|
+
printable(row.name)
|
|
895
|
+
])
|
|
896
|
+
]);
|
|
897
|
+
}
|
|
898
|
+
function elementLines(metric, elements) {
|
|
899
|
+
const lines = ["", `${label(metric)} elements behind slow visits`];
|
|
900
|
+
if (elements.length === 0) return [...lines, " (none)"];
|
|
901
|
+
for (const row of elements) {
|
|
902
|
+
lines.push(
|
|
903
|
+
` ${printable(row.element)} on ${printable(row.route)}: ${formatCount(row.slow)} slow visits, average ${formatValue(metric, row.average)}`
|
|
904
|
+
);
|
|
905
|
+
if (row.parts && row.parts.length > 0)
|
|
906
|
+
lines.push(
|
|
907
|
+
` ${row.parts.map((part) => `${partText(part.name)} ${formatMs(part.ms)}`).join(" \xB7 ")}`
|
|
908
|
+
);
|
|
909
|
+
if (row.url) lines.push(` image ${printable(row.url)}`);
|
|
910
|
+
}
|
|
911
|
+
return lines;
|
|
912
|
+
}
|
|
913
|
+
function deploymentName(row) {
|
|
914
|
+
if (row.source === "cli" || !row.commitSha) return row.id.slice(0, 8);
|
|
915
|
+
const sha = printable(row.commitSha.slice(0, 7));
|
|
916
|
+
const subject = [
|
|
917
|
+
...printable(row.commitMessage?.split("\n")[0]?.trim() ?? "")
|
|
918
|
+
];
|
|
919
|
+
if (subject.length === 0) return sha;
|
|
920
|
+
return subject.length > SUBJECT_LENGTH ? `${sha} ${subject.slice(0, SUBJECT_LENGTH - 1).join("")}\u2026` : `${sha} ${subject.join("")}`;
|
|
921
|
+
}
|
|
922
|
+
function changeText(metric, row) {
|
|
923
|
+
if (row.samples < SPEED_INSIGHTS_MIN_DEPLOYMENT_SAMPLES)
|
|
924
|
+
return "too few visits";
|
|
925
|
+
if (row.change === null) return "\u2013";
|
|
926
|
+
return formatChange(metric, row.change);
|
|
927
|
+
}
|
|
928
|
+
function deploymentLines(metric, deployments, environment) {
|
|
929
|
+
if (deployments === null) return [];
|
|
930
|
+
const lines = [
|
|
931
|
+
"",
|
|
932
|
+
`${label(metric)} by ${environmentWord(environment)}deployment, newest first`
|
|
933
|
+
];
|
|
934
|
+
const measured = deployments.filter((row) => row.samples > 0).slice(0, DEPLOYMENT_ROWS);
|
|
935
|
+
if (measured.length === 0) return [...lines, " (none)"];
|
|
936
|
+
return [
|
|
937
|
+
...lines,
|
|
938
|
+
...table([
|
|
939
|
+
["", "p75", "Visits", "Change", "Deployment"],
|
|
940
|
+
...measured.map((row) => [
|
|
941
|
+
row.liveUntil === null ? "Live" : "",
|
|
942
|
+
row.p75 === null ? "\u2014" : formatValue(metric, row.p75),
|
|
943
|
+
formatCount(row.samples),
|
|
944
|
+
changeText(metric, row),
|
|
945
|
+
deploymentName(row)
|
|
946
|
+
])
|
|
947
|
+
])
|
|
948
|
+
];
|
|
949
|
+
}
|
|
950
|
+
function reportLines(report, deployments, filters = {}) {
|
|
951
|
+
if (!report.enabled) return [OFF_LINE];
|
|
952
|
+
const { metric, range, environment } = report;
|
|
953
|
+
const filtered = filteredBy(filters);
|
|
954
|
+
const head = `Speed Insights \u2014 ${label(metric)}, ${rangeText(range)}${environmentSuffix(environment)}${filtered}`;
|
|
955
|
+
if (SPEED_INSIGHTS_METRICS.every((m) => report.summary[m].samples <= 0))
|
|
956
|
+
return [
|
|
957
|
+
head,
|
|
958
|
+
"",
|
|
959
|
+
// The dashboard's noFilteredSamples: waiting is the wrong advice when a
|
|
960
|
+
// filter is what emptied the report.
|
|
961
|
+
filtered === "" ? `No ${environmentWord(environment)}measurements in the ${rangeText(range)} yet. Values arrive when visitors leave a page.` : "No measurements match these filters. Drop a filter to see more."
|
|
962
|
+
];
|
|
963
|
+
const pageLoad = pageLoadLine(report.pageLoad);
|
|
964
|
+
return [
|
|
965
|
+
head,
|
|
966
|
+
"",
|
|
967
|
+
...summaryLines(report),
|
|
968
|
+
...pageLoad === null ? [] : ["", pageLoad],
|
|
969
|
+
"",
|
|
970
|
+
`${label(metric)} by page`,
|
|
971
|
+
...breakdownTable("Page", metric, report.paths),
|
|
972
|
+
"",
|
|
973
|
+
`${label(metric)} by device`,
|
|
974
|
+
...breakdownTable("Device", metric, report.devices),
|
|
975
|
+
...ATTRIBUTED.has(metric) ? elementLines(metric, report.elements) : [],
|
|
976
|
+
...deploymentLines(metric, deployments, environment)
|
|
977
|
+
];
|
|
978
|
+
}
|
|
979
|
+
function routePathsLines(result, filters = {}) {
|
|
980
|
+
return [
|
|
981
|
+
`${label(result.metric)} p75 under ${printable(result.route)}, ${rangeText(result.range)}${environmentSuffix(result.environment)}${filteredBy(filters)}`,
|
|
982
|
+
...breakdownTable("Path", result.metric, result.paths)
|
|
983
|
+
];
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// src/commands/analytics-format.ts
|
|
987
|
+
var TABLES = [
|
|
988
|
+
{ key: "pages", title: "Pages", column: "Page" },
|
|
989
|
+
{ key: "referrers", title: "Referrers", column: "Referrer" },
|
|
990
|
+
{ key: "countries", title: "Countries", column: "Country" },
|
|
991
|
+
{ key: "devices", title: "Devices", column: "Device" },
|
|
992
|
+
{ key: "browsers", title: "Browsers", column: "Browser" },
|
|
993
|
+
{ key: "os", title: "Operating systems", column: "Operating system" }
|
|
994
|
+
];
|
|
995
|
+
function breakdownLines({ title, column }, rows) {
|
|
996
|
+
if (rows.length === 0) return [title, " No data yet."];
|
|
997
|
+
return [
|
|
998
|
+
title,
|
|
999
|
+
...table([
|
|
1000
|
+
["Page views", column],
|
|
1001
|
+
// The name last: free text is never padded against anything after it.
|
|
1002
|
+
...rows.map((row) => [formatCount(row.views), printable(row.name)])
|
|
1003
|
+
])
|
|
1004
|
+
];
|
|
1005
|
+
}
|
|
1006
|
+
function analyticsLines(report, range, filters) {
|
|
1007
|
+
const filtered = filteredBy(filters);
|
|
1008
|
+
const head = `Visitors ${formatCount(report.totals.visitors)} \xB7 Page views ${formatCount(report.totals.views)} \xB7 last ${printable(range)}${filtered}`;
|
|
1009
|
+
if (filtered !== "" && report.totals.views === 0)
|
|
1010
|
+
return [
|
|
1011
|
+
head,
|
|
1012
|
+
"",
|
|
1013
|
+
"No visits match these filters. Drop a filter to see more."
|
|
1014
|
+
];
|
|
1015
|
+
return [
|
|
1016
|
+
head,
|
|
1017
|
+
...TABLES.flatMap((spec) => [
|
|
1018
|
+
"",
|
|
1019
|
+
...breakdownLines(spec, report[spec.key])
|
|
1020
|
+
])
|
|
1021
|
+
];
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// src/commands/analytics.ts
|
|
1025
|
+
function queryOf(flags) {
|
|
1026
|
+
const filters = filtersOf(flags, ANALYTICS_FILTER_KEYS);
|
|
1027
|
+
return {
|
|
1028
|
+
...flags.range !== void 0 ? { range: flags.range } : {},
|
|
1029
|
+
...Object.keys(filters).length > 0 ? { filters } : {}
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
async function runAnalytics(context) {
|
|
1033
|
+
if (context.flags.env !== void 0)
|
|
1034
|
+
throw new NaroError(
|
|
1035
|
+
"VALIDATION_FAILED",
|
|
1036
|
+
"Analytics reads the production worker only; drop --env."
|
|
1037
|
+
);
|
|
1038
|
+
const client = await context.client();
|
|
1039
|
+
const projectId = await context.resolveProjectId();
|
|
1040
|
+
const query = queryOf(context.flags);
|
|
1041
|
+
const report = await client.analytics.report(projectId, query);
|
|
1042
|
+
context.output.result(
|
|
1043
|
+
{ projectId, ...report },
|
|
1044
|
+
analyticsLines(report, report.range, query.filters ?? {})
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// src/commands/auth.ts
|
|
1049
|
+
async function runLogin(context) {
|
|
1050
|
+
const { output } = context;
|
|
1051
|
+
output.log("Opening your browser to sign in with GitHub...");
|
|
1052
|
+
const session = await login({
|
|
1053
|
+
openBrowser: (url) => {
|
|
1054
|
+
output.log(`If your browser did not open, visit:
|
|
1055
|
+
${url}`);
|
|
1056
|
+
openBrowser(url);
|
|
1057
|
+
}
|
|
1058
|
+
});
|
|
1059
|
+
saveSession(session);
|
|
1060
|
+
output.result(
|
|
1061
|
+
{
|
|
1062
|
+
user: {
|
|
1063
|
+
id: session.user.id,
|
|
1064
|
+
email: session.user.email,
|
|
1065
|
+
github: session.user.github
|
|
1066
|
+
}
|
|
1067
|
+
},
|
|
1068
|
+
[`\u2705 Logged in as ${session.user.email ?? session.user.id}`]
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
async function runLogout(context) {
|
|
1072
|
+
const session = loadStoredSession();
|
|
1073
|
+
if (session) {
|
|
1074
|
+
try {
|
|
1075
|
+
const client = createAuthClient();
|
|
1076
|
+
await client.auth.setSession({
|
|
1077
|
+
access_token: session.access_token,
|
|
1078
|
+
refresh_token: session.refresh_token
|
|
1079
|
+
});
|
|
1080
|
+
await client.auth.signOut();
|
|
1081
|
+
} catch {
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
clearSession();
|
|
1085
|
+
context.output.result({ loggedOut: true }, ["Logged out."]);
|
|
1086
|
+
}
|
|
1087
|
+
async function currentSession() {
|
|
1088
|
+
let session = loadStoredSession();
|
|
1089
|
+
if (!session)
|
|
1090
|
+
throw new NaroError("NOT_LOGGED_IN", "Not logged in. Run `naro login`.");
|
|
1091
|
+
if (isExpired(session)) {
|
|
1092
|
+
try {
|
|
1093
|
+
session = await refreshStoredSession(session.refresh_token);
|
|
1094
|
+
saveSession(session);
|
|
1095
|
+
} catch {
|
|
1096
|
+
throw new NaroError(
|
|
1097
|
+
"NOT_LOGGED_IN",
|
|
1098
|
+
"Session expired. Run `naro login`."
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
806
1101
|
}
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
1102
|
+
return session;
|
|
1103
|
+
}
|
|
1104
|
+
async function runWhoami(context) {
|
|
1105
|
+
const session = await currentSession();
|
|
1106
|
+
const handle = session.user.github ? ` (${session.user.github})` : "";
|
|
1107
|
+
context.output.result(
|
|
1108
|
+
{
|
|
1109
|
+
user: {
|
|
1110
|
+
id: session.user.id,
|
|
1111
|
+
email: session.user.email,
|
|
1112
|
+
github: session.user.github
|
|
1113
|
+
}
|
|
1114
|
+
},
|
|
1115
|
+
[`Logged in as ${session.user.email ?? session.user.id}${handle}`]
|
|
812
1116
|
);
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
1117
|
+
}
|
|
1118
|
+
async function runToken(context) {
|
|
1119
|
+
const session = await currentSession();
|
|
1120
|
+
process.stderr.write(
|
|
1121
|
+
"\u26A0 Access tokens expire after about an hour \u2014 generate one per CI run.\n"
|
|
1122
|
+
);
|
|
1123
|
+
context.output.result(
|
|
1124
|
+
{ token: session.access_token, expiresAt: session.expires_at },
|
|
1125
|
+
[session.access_token]
|
|
819
1126
|
);
|
|
820
|
-
context.output.result({ projectId, applied, pending: [], warnings }, [
|
|
821
|
-
`\u2705 Applied ${String(applied.length)} migration(s).`
|
|
822
|
-
]);
|
|
823
1127
|
}
|
|
824
1128
|
|
|
825
1129
|
// src/commands/backend.ts
|
|
@@ -1455,7 +1759,7 @@ async function allUsers(client, projectId) {
|
|
|
1455
1759
|
function orDash(value) {
|
|
1456
1760
|
return value === null ? "-" : printable(value);
|
|
1457
1761
|
}
|
|
1458
|
-
function
|
|
1762
|
+
function label2(user) {
|
|
1459
1763
|
return printable(user.username ?? user.email ?? user.id);
|
|
1460
1764
|
}
|
|
1461
1765
|
function userTable(users) {
|
|
@@ -1537,7 +1841,7 @@ async function runBackendUsersCreate(context, input = processStdin3) {
|
|
|
1537
1841
|
);
|
|
1538
1842
|
const signIn = user.username === null ? "naro.auth.signIn({ email, password })" : `naro.auth.signIn({ username: ${JSON.stringify(printable(user.username))}, password })`;
|
|
1539
1843
|
context.output.result({ projectId, user }, [
|
|
1540
|
-
`\u2705 Created user ${
|
|
1844
|
+
`\u2705 Created user ${label2(user)}. It signs in at once with ${signIn}.`,
|
|
1541
1845
|
...userLines(user)
|
|
1542
1846
|
]);
|
|
1543
1847
|
}
|
|
@@ -1562,7 +1866,7 @@ async function runBackendUsersDelete(context) {
|
|
|
1562
1866
|
"USER_NOT_FOUND",
|
|
1563
1867
|
`No user has the id, username or email ${printable(given)}. \`naro backend users\` lists them.`
|
|
1564
1868
|
);
|
|
1565
|
-
const named = user.id ===
|
|
1869
|
+
const named = user.id === label2(user) ? label2(user) : `${label2(user)} (${printable(user.id)})`;
|
|
1566
1870
|
await confirmOrAbort(
|
|
1567
1871
|
context,
|
|
1568
1872
|
`Delete the user ${named}? Their sessions end at once and the account cannot be brought back.`
|
|
@@ -4583,206 +4887,35 @@ async function runGenTypes(context) {
|
|
|
4583
4887
|
]);
|
|
4584
4888
|
}
|
|
4585
4889
|
|
|
4586
|
-
// src/commands/insights-format.ts
|
|
4587
|
-
var OFF_LINE = "Speed Insights is off for this project. Turn it on: naro insights enable";
|
|
4588
|
-
var TURNED_ON = "Speed Insights is on. Measuring starts within about a minute; values arrive when visitors leave a page.";
|
|
4589
|
-
var TURNED_OFF = "Speed Insights is off. Measuring stops within about a minute; turning it back on shows past measurements again.";
|
|
4590
|
-
var RANGE_TEXT = {
|
|
4591
|
-
"24h": "last 24 hours",
|
|
4592
|
-
"7d": "last 7 days",
|
|
4593
|
-
"30d": "last 30 days"
|
|
4594
|
-
};
|
|
4595
|
-
var PART_TEXT = {
|
|
4596
|
-
ttfb: "TTFB",
|
|
4597
|
-
loadDelay: "load delay",
|
|
4598
|
-
loadDuration: "load duration",
|
|
4599
|
-
renderDelay: "render delay",
|
|
4600
|
-
inputDelay: "input delay",
|
|
4601
|
-
processing: "processing",
|
|
4602
|
-
presentation: "presentation"
|
|
4603
|
-
};
|
|
4604
|
-
function rangeText(range) {
|
|
4605
|
-
const known = RANGE_TEXT;
|
|
4606
|
-
return known[range] ?? printable(range);
|
|
4607
|
-
}
|
|
4608
|
-
function partText(name) {
|
|
4609
|
-
const known = PART_TEXT;
|
|
4610
|
-
return known[name] ?? printable(name);
|
|
4611
|
-
}
|
|
4612
|
-
var ATTRIBUTED = /* @__PURE__ */ new Set([
|
|
4613
|
-
"lcp",
|
|
4614
|
-
"inp",
|
|
4615
|
-
"cls"
|
|
4616
|
-
]);
|
|
4617
|
-
var DEPLOYMENT_ROWS = 10;
|
|
4618
|
-
var SUBJECT_LENGTH = 50;
|
|
4619
|
-
function formatMs(ms) {
|
|
4620
|
-
const rounded = Math.round(ms);
|
|
4621
|
-
return rounded < 1e3 ? `${String(rounded)} ms` : `${(rounded / 1e3).toFixed(2)} s`;
|
|
4622
|
-
}
|
|
4623
|
-
function formatValue(metric, value) {
|
|
4624
|
-
return metric === "cls" ? value.toFixed(2) : formatMs(value);
|
|
4625
|
-
}
|
|
4626
|
-
function formatChange(metric, change2) {
|
|
4627
|
-
const size = formatValue(metric, Math.abs(change2));
|
|
4628
|
-
if (Number.parseFloat(size) === 0) return "\xB10";
|
|
4629
|
-
return change2 > 0 ? `\u25B2 +${size}` : `\u25BC \u2212${size}`;
|
|
4630
|
-
}
|
|
4631
|
-
function formatCount(count2) {
|
|
4632
|
-
return Math.round(count2).toLocaleString("en-US");
|
|
4633
|
-
}
|
|
4634
|
-
function percent(part, total) {
|
|
4635
|
-
return `${String(Math.round(part / total * 100))}%`;
|
|
4636
|
-
}
|
|
4637
|
-
function label2(metric) {
|
|
4638
|
-
return metric.toUpperCase();
|
|
4639
|
-
}
|
|
4640
|
-
function environmentSuffix(environment) {
|
|
4641
|
-
return environment === "preview" ? ", preview" : "";
|
|
4642
|
-
}
|
|
4643
|
-
function environmentWord(environment) {
|
|
4644
|
-
return environment === "preview" ? "preview " : "";
|
|
4645
|
-
}
|
|
4646
|
-
function table(rows) {
|
|
4647
|
-
const widths = [];
|
|
4648
|
-
for (const row of rows)
|
|
4649
|
-
row.forEach((cell, i) => {
|
|
4650
|
-
widths[i] = Math.max(widths[i] ?? 0, cell.length);
|
|
4651
|
-
});
|
|
4652
|
-
return rows.map(
|
|
4653
|
-
(row) => ` ${row.map((cell, i) => cell.padEnd(widths[i] ?? 0)).join(" ")}`.trimEnd()
|
|
4654
|
-
);
|
|
4655
|
-
}
|
|
4656
|
-
function summaryLines(report) {
|
|
4657
|
-
return table([
|
|
4658
|
-
["Metric", "p75", "Good", "Needs improvement", "Poor", "Visits"],
|
|
4659
|
-
...SPEED_INSIGHTS_METRICS.map((metric) => {
|
|
4660
|
-
const summary = report.summary[metric];
|
|
4661
|
-
if (summary.p75 === null || summary.samples <= 0)
|
|
4662
|
-
return [label2(metric), "\u2014", "\u2014", "\u2014", "\u2014", "0"];
|
|
4663
|
-
return [
|
|
4664
|
-
label2(metric),
|
|
4665
|
-
formatValue(metric, summary.p75),
|
|
4666
|
-
percent(summary.good, summary.samples),
|
|
4667
|
-
percent(summary.needsImprovement, summary.samples),
|
|
4668
|
-
percent(summary.poor, summary.samples),
|
|
4669
|
-
formatCount(summary.samples)
|
|
4670
|
-
];
|
|
4671
|
-
})
|
|
4672
|
-
]);
|
|
4673
|
-
}
|
|
4674
|
-
function breakdownTable(heading, metric, rows) {
|
|
4675
|
-
if (rows.length === 0) return [" (none)"];
|
|
4676
|
-
return table([
|
|
4677
|
-
["p75", "Visits", heading],
|
|
4678
|
-
...rows.map((row) => [
|
|
4679
|
-
formatValue(metric, row.p75),
|
|
4680
|
-
formatCount(row.samples),
|
|
4681
|
-
printable(row.name)
|
|
4682
|
-
])
|
|
4683
|
-
]);
|
|
4684
|
-
}
|
|
4685
|
-
function elementLines(metric, elements) {
|
|
4686
|
-
const lines = ["", `${label2(metric)} elements behind slow visits`];
|
|
4687
|
-
if (elements.length === 0) return [...lines, " (none)"];
|
|
4688
|
-
for (const row of elements) {
|
|
4689
|
-
lines.push(
|
|
4690
|
-
` ${printable(row.element)} on ${printable(row.route)}: ${formatCount(row.slow)} slow visits, average ${formatValue(metric, row.average)}`
|
|
4691
|
-
);
|
|
4692
|
-
if (row.parts && row.parts.length > 0)
|
|
4693
|
-
lines.push(
|
|
4694
|
-
` ${row.parts.map((part) => `${partText(part.name)} ${formatMs(part.ms)}`).join(" \xB7 ")}`
|
|
4695
|
-
);
|
|
4696
|
-
if (row.url) lines.push(` image ${printable(row.url)}`);
|
|
4697
|
-
}
|
|
4698
|
-
return lines;
|
|
4699
|
-
}
|
|
4700
|
-
function deploymentName(row) {
|
|
4701
|
-
if (row.source === "cli" || !row.commitSha) return row.id.slice(0, 8);
|
|
4702
|
-
const sha = printable(row.commitSha.slice(0, 7));
|
|
4703
|
-
const subject = [
|
|
4704
|
-
...printable(row.commitMessage?.split("\n")[0]?.trim() ?? "")
|
|
4705
|
-
];
|
|
4706
|
-
if (subject.length === 0) return sha;
|
|
4707
|
-
return subject.length > SUBJECT_LENGTH ? `${sha} ${subject.slice(0, SUBJECT_LENGTH - 1).join("")}\u2026` : `${sha} ${subject.join("")}`;
|
|
4708
|
-
}
|
|
4709
|
-
function changeText(metric, row) {
|
|
4710
|
-
if (row.samples < SPEED_INSIGHTS_MIN_DEPLOYMENT_SAMPLES)
|
|
4711
|
-
return "too few visits";
|
|
4712
|
-
if (row.change === null) return "\u2013";
|
|
4713
|
-
return formatChange(metric, row.change);
|
|
4714
|
-
}
|
|
4715
|
-
function deploymentLines(metric, deployments, environment) {
|
|
4716
|
-
if (deployments === null) return [];
|
|
4717
|
-
const lines = [
|
|
4718
|
-
"",
|
|
4719
|
-
`${label2(metric)} by ${environmentWord(environment)}deployment, newest first`
|
|
4720
|
-
];
|
|
4721
|
-
const measured = deployments.filter((row) => row.samples > 0).slice(0, DEPLOYMENT_ROWS);
|
|
4722
|
-
if (measured.length === 0) return [...lines, " (none)"];
|
|
4723
|
-
return [
|
|
4724
|
-
...lines,
|
|
4725
|
-
...table([
|
|
4726
|
-
["", "p75", "Visits", "Change", "Deployment"],
|
|
4727
|
-
...measured.map((row) => [
|
|
4728
|
-
row.liveUntil === null ? "Live" : "",
|
|
4729
|
-
row.p75 === null ? "\u2014" : formatValue(metric, row.p75),
|
|
4730
|
-
formatCount(row.samples),
|
|
4731
|
-
changeText(metric, row),
|
|
4732
|
-
deploymentName(row)
|
|
4733
|
-
])
|
|
4734
|
-
])
|
|
4735
|
-
];
|
|
4736
|
-
}
|
|
4737
|
-
function reportLines(report, deployments) {
|
|
4738
|
-
if (!report.enabled) return [OFF_LINE];
|
|
4739
|
-
const { metric, range, environment } = report;
|
|
4740
|
-
const head = `Speed Insights \u2014 ${label2(metric)}, ${rangeText(range)}${environmentSuffix(environment)}`;
|
|
4741
|
-
if (SPEED_INSIGHTS_METRICS.every((m) => report.summary[m].samples <= 0))
|
|
4742
|
-
return [
|
|
4743
|
-
head,
|
|
4744
|
-
"",
|
|
4745
|
-
`No ${environmentWord(environment)}measurements in the ${rangeText(range)} yet. Values arrive when visitors leave a page.`
|
|
4746
|
-
];
|
|
4747
|
-
return [
|
|
4748
|
-
head,
|
|
4749
|
-
"",
|
|
4750
|
-
...summaryLines(report),
|
|
4751
|
-
"",
|
|
4752
|
-
`${label2(metric)} by page`,
|
|
4753
|
-
...breakdownTable("Page", metric, report.paths),
|
|
4754
|
-
"",
|
|
4755
|
-
`${label2(metric)} by device`,
|
|
4756
|
-
...breakdownTable("Device", metric, report.devices),
|
|
4757
|
-
...ATTRIBUTED.has(metric) ? elementLines(metric, report.elements) : [],
|
|
4758
|
-
...deploymentLines(metric, deployments, environment)
|
|
4759
|
-
];
|
|
4760
|
-
}
|
|
4761
|
-
function routePathsLines(result) {
|
|
4762
|
-
return [
|
|
4763
|
-
`${label2(result.metric)} p75 under ${printable(result.route)}, ${rangeText(result.range)}${environmentSuffix(result.environment)}`,
|
|
4764
|
-
...breakdownTable("Path", result.metric, result.paths)
|
|
4765
|
-
];
|
|
4766
|
-
}
|
|
4767
|
-
|
|
4768
4890
|
// src/commands/insights.ts
|
|
4769
|
-
function
|
|
4891
|
+
function queryOf2(flags) {
|
|
4892
|
+
const filters = filtersOf(flags, SPEED_INSIGHTS_FILTER_KEYS);
|
|
4770
4893
|
return {
|
|
4771
4894
|
...flags.range !== void 0 ? { range: flags.range } : {},
|
|
4772
4895
|
...flags.metric !== void 0 ? { metric: flags.metric } : {},
|
|
4773
|
-
...flags.env !== void 0 ? { environment: flags.env } : {}
|
|
4896
|
+
...flags.env !== void 0 ? { environment: flags.env } : {},
|
|
4897
|
+
...Object.keys(filters).length > 0 ? { filters } : {}
|
|
4774
4898
|
};
|
|
4775
4899
|
}
|
|
4776
4900
|
async function runInsights(context) {
|
|
4901
|
+
if (context.flags.referrer !== void 0 && context.flags.referrer !== "")
|
|
4902
|
+
throw new NaroError(
|
|
4903
|
+
"VALIDATION_FAILED",
|
|
4904
|
+
"Speed Insights has no referrer; drop --referrer."
|
|
4905
|
+
);
|
|
4777
4906
|
const client = await context.client();
|
|
4778
4907
|
const projectId = await context.resolveProjectId();
|
|
4779
|
-
const query =
|
|
4908
|
+
const query = queryOf2(context.flags);
|
|
4909
|
+
const filters = query.filters ?? {};
|
|
4780
4910
|
if (context.flags.route !== void 0) {
|
|
4781
4911
|
const result = await client.insights.paths(projectId, {
|
|
4782
4912
|
...query,
|
|
4783
4913
|
route: context.flags.route
|
|
4784
4914
|
});
|
|
4785
|
-
context.output.result(
|
|
4915
|
+
context.output.result(
|
|
4916
|
+
{ projectId, ...result },
|
|
4917
|
+
routePathsLines(result, filters)
|
|
4918
|
+
);
|
|
4786
4919
|
return;
|
|
4787
4920
|
}
|
|
4788
4921
|
const overview = await client.insights.overview(projectId, query);
|
|
@@ -4792,7 +4925,7 @@ async function runInsights(context) {
|
|
|
4792
4925
|
);
|
|
4793
4926
|
context.output.result(
|
|
4794
4927
|
{ projectId, ...overview },
|
|
4795
|
-
reportLines(overview.report, overview.deployments)
|
|
4928
|
+
reportLines(overview.report, overview.deployments, filters)
|
|
4796
4929
|
);
|
|
4797
4930
|
}
|
|
4798
4931
|
async function setInsights(context, enabled) {
|
|
@@ -4801,6 +4934,12 @@ async function setInsights(context, enabled) {
|
|
|
4801
4934
|
"VALIDATION_FAILED",
|
|
4802
4935
|
"Speed Insights has one switch for production and preview; drop --env."
|
|
4803
4936
|
);
|
|
4937
|
+
const filter = givenFilterFlag(context.flags);
|
|
4938
|
+
if (filter !== void 0)
|
|
4939
|
+
throw new NaroError(
|
|
4940
|
+
"VALIDATION_FAILED",
|
|
4941
|
+
`Filters only narrow a report; drop --${filter}.`
|
|
4942
|
+
);
|
|
4804
4943
|
const client = await context.client();
|
|
4805
4944
|
const projectId = await context.resolveProjectId();
|
|
4806
4945
|
const result = await client.insights.setEnabled(projectId, enabled);
|
|
@@ -8061,9 +8200,13 @@ Usage:
|
|
|
8061
8200
|
naro logs [deployment-id] Runtime logs (or build logs of a deployment)
|
|
8062
8201
|
[--type build|runtime] [--env production|preview] [--level error] [--follow]
|
|
8063
8202
|
|
|
8064
|
-
naro
|
|
8203
|
+
naro analytics [--range 7d] [--path p] [--referrer host] [--country KR] [--device mobile] [--browser safari] [--os ios]
|
|
8204
|
+
Web Analytics: visitors and page views, then the top pages,
|
|
8205
|
+
referrers, countries, devices, browsers and operating systems
|
|
8206
|
+
naro insights [--metric lcp] [--range 7d] [--env preview] [--path p] [--country KR] [--device mobile] [--browser safari] [--os ios]
|
|
8065
8207
|
Speed Insights: Web Vitals p75 by page, device, the elements
|
|
8066
|
-
behind slow visits and deployment
|
|
8208
|
+
behind slow visits and deployment, and the page load: its p75
|
|
8209
|
+
and the mean of each step (production by default)
|
|
8067
8210
|
naro insights --route '/posts/[id]' The paths opened under one route
|
|
8068
8211
|
naro insights enable | disable Turn measuring on or off (production pages get a
|
|
8069
8212
|
same-origin script, preview pages too; past measurements are kept)
|
|
@@ -8221,8 +8364,14 @@ Global options:
|
|
|
8221
8364
|
--prod Deploy/target production
|
|
8222
8365
|
--wait Poll until the domain goes live (naro domains verify)
|
|
8223
8366
|
--metric <m> lcp, inp, cls, fcp or ttfb; lcp by default (naro insights)
|
|
8224
|
-
--range <r> 24h, 7d or 30d; 7d by default (naro insights)
|
|
8367
|
+
--range <r> 24h, 7d or 30d; 7d by default (naro analytics, naro insights)
|
|
8225
8368
|
--route <pattern> A route from the pages table, e.g. '/posts/[id]': the paths under it (naro insights)
|
|
8369
|
+
--path <p> Only this page, starting with /, e.g. /pricing (naro analytics, naro insights)
|
|
8370
|
+
--referrer <host> Only visits that came from this lowercase host, e.g. t.co (naro analytics only: Speed Insights has no referrer)
|
|
8371
|
+
--country <cc> Only this country, an uppercase two-letter code like KR (naro analytics, naro insights)
|
|
8372
|
+
--device <d> Only this device: desktop, mobile or tablet (naro analytics, naro insights)
|
|
8373
|
+
--browser <b> Only this browser: chrome, edge, firefox, opera, safari or other (naro analytics, naro insights)
|
|
8374
|
+
--os <o> Only this operating system: android, ios, linux, macos, windows or other (naro analytics, naro insights)
|
|
8226
8375
|
--env <e> production or preview (naro logs, naro insights: production by default; naro env pull: preview by default)
|
|
8227
8376
|
--no-www Register the apex alone (naro domains add)
|
|
8228
8377
|
--file <path> SQL file to run (naro db query, naro policy create)
|
|
@@ -8438,6 +8587,9 @@ function route(parsed) {
|
|
|
8438
8587
|
return { handler: runCancel, positionals };
|
|
8439
8588
|
case "logs":
|
|
8440
8589
|
return { handler: runLogs, positionals };
|
|
8590
|
+
// Spec 2026-10-08-analytics-cli-pageload §5.
|
|
8591
|
+
case "analytics":
|
|
8592
|
+
return positionals.length === 0 ? { handler: runAnalytics, positionals: [] } : null;
|
|
8441
8593
|
// Spec 2026-10-08 §5.
|
|
8442
8594
|
case "insights": {
|
|
8443
8595
|
if (positionals.length > 1) return null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "naro-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"naro": "./dist/index.js"
|
|
@@ -34,11 +34,11 @@
|
|
|
34
34
|
"typescript": "7.0.2",
|
|
35
35
|
"vitest": "3.2.7",
|
|
36
36
|
"naro-js": "0.3.0",
|
|
37
|
-
"@next-deploy/data-core": "0.1.0",
|
|
38
37
|
"@next-deploy/build": "0.1.0",
|
|
39
38
|
"@next-deploy/config": "0.1.0",
|
|
40
39
|
"@next-deploy/core": "0.1.0",
|
|
41
|
-
"@next-deploy/sdk": "0.1.0"
|
|
40
|
+
"@next-deploy/sdk": "0.1.0",
|
|
41
|
+
"@next-deploy/data-core": "0.1.0"
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|
|
44
44
|
"build": "tsup && pnpm build:types",
|