@tokenoftrust/cli 1.3.2 → 1.3.3-rc.1
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/package.json +1 -1
- package/src/commands/dev.mjs +3 -2
- package/src/dev-heartbeat.mjs +36 -7
- package/src/validate.mjs +25 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.3-rc.1",
|
|
4
4
|
"description": "Token of Trust developer CLI — check out a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Token of Trust",
|
package/src/commands/dev.mjs
CHANGED
|
@@ -314,7 +314,8 @@ export function bootNative(runnerDir, workspace, port, url, args) {
|
|
|
314
314
|
// Heartbeat the hosted cockpit (G1) with the CLI version + this live localhost
|
|
315
315
|
// URL while the runner runs — CLI-side, using the SAME bridge credential the
|
|
316
316
|
// runner's file-save path uses (no-op in --sample, which threads no credential).
|
|
317
|
-
|
|
317
|
+
// `cwd` (the checkout root) lets the cockpit show the FULL path to edit.
|
|
318
|
+
const stopHeartbeat = startHeartbeatFromEnv(bridgeEnv, { url, cwd: workspace });
|
|
318
319
|
handle.done.finally(() => stopHeartbeat());
|
|
319
320
|
|
|
320
321
|
// Auto-open the browser the moment the server answers (D). Non-blocking so
|
|
@@ -997,7 +998,7 @@ async function runContainer(workspace, args, ctx) {
|
|
|
997
998
|
// Heartbeat the hosted cockpit (G1) CLI-side while the container runs — the
|
|
998
999
|
// container reports file-saves via the threaded env, but the CLI owns the
|
|
999
1000
|
// version + live URL. Same bridge credential (activityBridgeEnv), no-op absent.
|
|
1000
|
-
const stopHeartbeat = startHeartbeatFromEnv(activityBridgeEnv(), { url: plan.url });
|
|
1001
|
+
const stopHeartbeat = startHeartbeatFromEnv(activityBridgeEnv(), { url: plan.url, cwd: plan.workspace });
|
|
1001
1002
|
handle.done.finally(() => stopHeartbeat());
|
|
1002
1003
|
|
|
1003
1004
|
// Auto-open the browser the moment the server answers (D). Non-blocking so
|
package/src/dev-heartbeat.mjs
CHANGED
|
@@ -29,14 +29,22 @@ const HEARTBEAT_INTERVAL_MS = 10_000;
|
|
|
29
29
|
* an activity URL and a bearer token. Never throws — a failed/offline hosted
|
|
30
30
|
* worker just means the cockpit doesn't light up this beat.
|
|
31
31
|
* @param {{ activityUrl?: string, token?: string, url?: string,
|
|
32
|
-
* cliVersion?: string, runnerVersion?: string|null }} args
|
|
32
|
+
* cliVersion?: string, runnerVersion?: string|null, editor?: string|null }} args
|
|
33
33
|
*/
|
|
34
|
-
export function postHeartbeat({ activityUrl, token, url, cliVersion, runnerVersion } = {}) {
|
|
34
|
+
export function postHeartbeat({ activityUrl, token, url, cliVersion, runnerVersion, editor, cwd } = {}) {
|
|
35
35
|
if (!activityUrl || !token) return undefined;
|
|
36
36
|
/** @type {Record<string, unknown>} */
|
|
37
37
|
const body = { event: "heartbeat", cliVersion, at: Date.now() };
|
|
38
38
|
if (runnerVersion) body.runnerVersion = runnerVersion;
|
|
39
39
|
if (url) body.url = url;
|
|
40
|
+
// The developer's terminal $EDITOR (if set) — lets the cockpit suggest the exact
|
|
41
|
+
// "open this file" command (e.g. `code content/home.html`). Never a secret; a
|
|
42
|
+
// no-op when unset (the cockpit falls back to a generic hint).
|
|
43
|
+
if (editor) body.editor = editor;
|
|
44
|
+
// The project directory (the checkout root) — lets the cockpit show the FULL path
|
|
45
|
+
// to the file to edit so the command works from any directory. The dev's own local
|
|
46
|
+
// path (low sensitivity, like `url`); absent → the cockpit uses a relative path.
|
|
47
|
+
if (cwd) body.cwd = cwd;
|
|
40
48
|
return fetch(`${String(activityUrl).replace(/\/+$/, "")}/api/dev/activity`, {
|
|
41
49
|
method: "POST",
|
|
42
50
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
@@ -52,7 +60,8 @@ export function postHeartbeat({ activityUrl, token, url, cliVersion, runnerVersi
|
|
|
52
60
|
* Returns a no-op stop() when there's no bridge credential (sample / not-signed-
|
|
53
61
|
* in), so callers can wire it unconditionally.
|
|
54
62
|
* @param {{ activityUrl?: string, token?: string, url?: string,
|
|
55
|
-
* cliVersion?: string, runnerVersion?: string|null,
|
|
63
|
+
* cliVersion?: string, runnerVersion?: string|null, editor?: string|null,
|
|
64
|
+
* cwd?: string|null, intervalMs?: number }} [opts]
|
|
56
65
|
* @returns {() => void} stop the heartbeat (idempotent).
|
|
57
66
|
*/
|
|
58
67
|
export function startDevHeartbeat({
|
|
@@ -61,32 +70,52 @@ export function startDevHeartbeat({
|
|
|
61
70
|
url,
|
|
62
71
|
cliVersion = CLI_VERSION,
|
|
63
72
|
runnerVersion,
|
|
73
|
+
editor,
|
|
74
|
+
cwd,
|
|
64
75
|
intervalMs = HEARTBEAT_INTERVAL_MS,
|
|
65
76
|
} = {}) {
|
|
66
77
|
if (!activityUrl || !token) return () => {}; // no bridge / sample mode → no-op
|
|
67
|
-
const beat = () => postHeartbeat({ activityUrl, token, url, cliVersion, runnerVersion });
|
|
78
|
+
const beat = () => postHeartbeat({ activityUrl, token, url, cliVersion, runnerVersion, editor, cwd });
|
|
68
79
|
beat();
|
|
69
80
|
const timer = setInterval(beat, intervalMs);
|
|
70
81
|
if (typeof timer.unref === "function") timer.unref();
|
|
71
82
|
return () => clearInterval(timer);
|
|
72
83
|
}
|
|
73
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Resolve the developer's terminal editor command for the "open this file"
|
|
87
|
+
* cockpit hint — VISUAL wins over EDITOR (the POSIX convention: VISUAL is the
|
|
88
|
+
* full-screen editor, EDITOR the line-editor fallback). Returns the trimmed
|
|
89
|
+
* command (which may carry flags, e.g. "code --wait") or undefined when neither
|
|
90
|
+
* is set — in which case the cockpit shows a generic hint instead of a wrong one.
|
|
91
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
92
|
+
* @returns {string|undefined}
|
|
93
|
+
*/
|
|
94
|
+
export function resolveEditor(env = process.env) {
|
|
95
|
+
const e = String(env?.VISUAL || env?.EDITOR || "").trim();
|
|
96
|
+
return e || undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
74
99
|
/**
|
|
75
100
|
* Convenience wrapper for the runner-spawn sites: start a heartbeat from the
|
|
76
101
|
* same bridge env that's threaded to the runner ({TOT_DEV_ACTIVITY_URL,
|
|
77
102
|
* TOT_DEV_ACTIVITY_TOKEN}, or {} for the `--sample` path — which yields a no-op,
|
|
78
103
|
* exactly the desired "never leak a real credential from sample" behavior). The
|
|
79
104
|
* runner version defaults to whatever `tot dev` resolved this invocation
|
|
80
|
-
* (clientPackages().runner — null until the runner is resolved).
|
|
105
|
+
* (clientPackages().runner — null until the runner is resolved). The editor comes
|
|
106
|
+
* from the CLI's own env (VISUAL/EDITOR) so the cockpit can suggest the exact
|
|
107
|
+
* open-file command.
|
|
81
108
|
* @param {{ TOT_DEV_ACTIVITY_URL?: string, TOT_DEV_ACTIVITY_TOKEN?: string }} bridgeEnv
|
|
82
|
-
* @param {{ url?: string, runnerVersion?: string|null }} [opts]
|
|
109
|
+
* @param {{ url?: string, runnerVersion?: string|null, cwd?: string|null, env?: NodeJS.ProcessEnv }} [opts]
|
|
83
110
|
* @returns {() => void} stop the heartbeat.
|
|
84
111
|
*/
|
|
85
|
-
export function startHeartbeatFromEnv(bridgeEnv, { url, runnerVersion } = {}) {
|
|
112
|
+
export function startHeartbeatFromEnv(bridgeEnv, { url, runnerVersion, cwd, env = process.env } = {}) {
|
|
86
113
|
return startDevHeartbeat({
|
|
87
114
|
activityUrl: bridgeEnv?.TOT_DEV_ACTIVITY_URL,
|
|
88
115
|
token: bridgeEnv?.TOT_DEV_ACTIVITY_TOKEN,
|
|
89
116
|
url,
|
|
90
117
|
runnerVersion: runnerVersion ?? clientPackages().runner,
|
|
118
|
+
editor: resolveEditor(env),
|
|
119
|
+
cwd,
|
|
91
120
|
});
|
|
92
121
|
}
|
package/src/validate.mjs
CHANGED
|
@@ -96,6 +96,28 @@ function validateRawHtmlBody(html) {
|
|
|
96
96
|
if (!html.includes("<")) return "no markup tags found";
|
|
97
97
|
return null;
|
|
98
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Does the tenant provide chrome to wrap body-only fragments? True when it ships
|
|
101
|
+
* EITHER a hand-authored `content/chrome.html`, OR a `content/chrome.json` that
|
|
102
|
+
* opts into the canonical shared chrome. Mirrors the serve-time resolution in
|
|
103
|
+
* apps/storefront getChromeHtml() → parseChromeConfig(): an opted-in config is
|
|
104
|
+
* rendered into the wrapper (renderRawChrome), so a fragment is NOT served naked.
|
|
105
|
+
* Kept in step with lib/chrome/parseConfig.ts's opt-in gate.
|
|
106
|
+
*/
|
|
107
|
+
function providesChrome(contentDir) {
|
|
108
|
+
if (existsSync(join(contentDir, "chrome.html"))) return true;
|
|
109
|
+
const configPath = join(contentDir, "chrome.json");
|
|
110
|
+
if (!existsSync(configPath)) return false;
|
|
111
|
+
const { value } = readJsonSafe(configPath);
|
|
112
|
+
if (value == null || typeof value !== "object") return false;
|
|
113
|
+
if (value.sharedChrome !== true) return false;
|
|
114
|
+
const brand = value.brand;
|
|
115
|
+
if (brand == null || typeof brand !== "object" || typeof brand.label !== "string") return false;
|
|
116
|
+
if (!Array.isArray(value.nav)) return false;
|
|
117
|
+
const footer = value.footer;
|
|
118
|
+
if (footer == null || typeof footer !== "object" || !Array.isArray(footer.columns)) return false;
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
99
121
|
|
|
100
122
|
// --- filesystem helpers ------------------------------------------------------
|
|
101
123
|
function readJsonSafe(path) {
|
|
@@ -186,8 +208,7 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
186
208
|
}
|
|
187
209
|
|
|
188
210
|
// 4. raw HTML documents — validate + portability + resolution
|
|
189
|
-
const
|
|
190
|
-
const hasChrome = existsSync(chromePath);
|
|
211
|
+
const hasChrome = providesChrome(contentDir);
|
|
191
212
|
const htmlFiles = walk(contentDir, (p) => p.endsWith(".html"));
|
|
192
213
|
const pageTargets = buildPageTargetSet(contentDir, pagesDir);
|
|
193
214
|
|
|
@@ -204,8 +225,8 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
204
225
|
if (!base && !isFullDocument(html) && !hasChrome) {
|
|
205
226
|
findings.push(
|
|
206
227
|
mk(ERROR, "html-fragment", r,
|
|
207
|
-
"is an HTML fragment but the tenant ships no
|
|
208
|
-
"make it a full <!doctype html> document, or add content/chrome.html with <!--PAGE_BODY-->"),
|
|
228
|
+
"is an HTML fragment but the tenant ships no chrome to wrap it — it will serve unwrapped/naked",
|
|
229
|
+
"make it a full <!doctype html> document, add a content/chrome.json with \"sharedChrome\": true (brand + nav + footer), or add content/chrome.html with <!--PAGE_BODY-->"),
|
|
209
230
|
);
|
|
210
231
|
}
|
|
211
232
|
if (STYLE_OPEN_WITH_ATTRS_RE.test(html)) {
|