@foldspace_npm/harness 0.1.33 → 0.1.34
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/CLAUDE.md +169 -49
- package/README.md +30 -2
- package/bin/inject.mjs +2 -18
- package/bin/observe.mjs +18 -0
- package/bin/record.mjs +2 -0
- package/bin/sync.mjs +48 -0
- package/package.json +1 -1
- package/recipes/INDEX.md +1 -1
- package/recipes/bottom-bar/README.md +24 -11
- package/recipes/bottom-bar/agent/bottomBar.ts +76 -38
- package/recipes/bottom-bar/fixtures/configuration.sent.json +13 -12
- package/recipes/swap-the-login-method/README.md +1 -1
- package/recipes/swap-the-login-method/agent/utils.ts +8 -7
- package/recipes/swap-the-login-method/variants/utils.cookies.ts +8 -7
- package/src/chrome-binary.mjs +33 -0
- package/src/cli-registry.mjs +52 -5
- package/src/customer-subdomain.mjs +149 -0
- package/src/lint/index.mjs +23 -3
- package/src/lint/rules.mjs +46 -2
- package/src/lint/safety.mjs +785 -0
- package/src/observe-core.mjs +1 -1
- package/src/runtime/VERSION +1 -1
- package/src/runtime/config.ts +15 -2
- package/src/runtime/http.ts +40 -3
- package/src/runtime/index.ts +1 -0
- package/src/sync-core.mjs +374 -0
- package/src/upgrade.mjs +36 -7
- package/src/video/compose.mjs +12 -3
- package/src/video/recorder.mjs +15 -7
- package/templates/agent-starter/agent/constants.ts +15 -1
- package/templates/agent-starter/agent/utils.ts +3 -0
- package/templates/agent-starter/claude-settings.json +3 -1
- package/templates/agent-starter/skills/review-experience/SKILL.md +135 -0
- package/templates/agent-starter/skills/share-a-video/SKILL.md +9 -2
|
@@ -1,52 +1,64 @@
|
|
|
1
1
|
// The bottom bar: the agent's resting entry point at the foot of the page,
|
|
2
|
-
//
|
|
3
|
-
// Agent Studio has no UI for it today, and this is how customers set it.
|
|
2
|
+
// showing starters that fit the screen the user is on.
|
|
4
3
|
// https://docs.foldspace.ai/customize/bottom-bar/
|
|
5
4
|
// https://docs.foldspace.ai/customize/conversation-starters/
|
|
6
5
|
//
|
|
6
|
+
// Foldspace turns the bar on from its own settings, so this code never sends
|
|
7
|
+
// `bottomBarSettings`: it only picks each page's starters.
|
|
8
|
+
//
|
|
7
9
|
// Product-neutral: the paths and starters below are placeholders. Replace them
|
|
8
|
-
// with this product's
|
|
10
|
+
// with this product's main-menu screens (`npx foldspace observe menu`) and,
|
|
11
|
+
// for each, starters the agent can answer today.
|
|
9
12
|
|
|
10
13
|
import { getAgent } from "./utils";
|
|
11
14
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* The most starters one page shows, both groups together. The same call sets
|
|
17
|
+
* the agent panel's opening screen, and a long list there buries the one
|
|
18
|
+
* that matters. `npx foldspace lint` refuses more.
|
|
19
|
+
*/
|
|
20
|
+
export const MAX_STARTERS_PER_PAGE = 3;
|
|
17
21
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Starters per screen. Paths are matched as prefixes, first match wins.
|
|
24
|
+
* - `ACTION`: clicking it runs one of the agent's actions (the bar shows the
|
|
25
|
+
* action icon), e.g. "Show my open invoices".
|
|
26
|
+
* - `KNOWLEDGE`: a question answered from what the agent knows (its knowledge
|
|
27
|
+
* base), e.g. "How do I add a teammate?".
|
|
28
|
+
* A page that is not listed shows Agent Studio's starters.
|
|
29
|
+
*/
|
|
30
|
+
export const PAGE_STARTERS: Array<{ match: RegExp; KNOWLEDGE: string[]; ACTION: string[] }> = [
|
|
31
|
+
{ match: /^\/dashboard/, KNOWLEDGE: [], ACTION: ["<Something an action shows about this screen>"] },
|
|
32
|
+
{ match: /^\/<records>/, KNOWLEDGE: ["<A how-to question about this screen>"], ACTION: ["Find <a record> by name"] },
|
|
33
|
+
];
|
|
20
34
|
|
|
21
|
-
|
|
22
|
-
// `false`, and the bar then never renders) and `maxVisibleStarters: 3` (the
|
|
23
|
-
// docs default 5). Change any of them only if the human asks: every field is
|
|
24
|
-
// documented on the Bottom Bar page.
|
|
25
|
-
export const BOTTOM_BAR_SETTINGS = {
|
|
26
|
-
enabled: true,
|
|
27
|
-
maxVisibleStarters: 3,
|
|
28
|
-
reopenFrequency: "EVERY_LOAD",
|
|
29
|
-
starterClickBehavior: "OPEN_AGENT",
|
|
30
|
-
idleTimeoutMs: 5000,
|
|
31
|
-
initialBehaviorMode: "FULL",
|
|
32
|
-
} as const;
|
|
35
|
+
export type PageStarters = (typeof PAGE_STARTERS)[number];
|
|
33
36
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return
|
|
37
|
+
/** This page's starters, or null for a page that keeps Agent Studio's. */
|
|
38
|
+
export function startersForPath(pathname: string): PageStarters | null {
|
|
39
|
+
return PAGE_STARTERS.find((entry) => entry.match.test(pathname)) ?? null;
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
// On window, not in module scope: a bundle evaluated twice on one page must
|
|
40
43
|
// not install two route listeners.
|
|
41
44
|
const INSTALLED_FLAG = "__foldspace_bottom_bar__";
|
|
42
45
|
|
|
46
|
+
const warned = new Set<string>();
|
|
47
|
+
function warnOnce(method: string): void {
|
|
48
|
+
if (warned.has(method)) return;
|
|
49
|
+
warned.add(method);
|
|
50
|
+
console.warn(
|
|
51
|
+
`[foldspace] This page's Foldspace SDK has no ${method}(), so the bottom bar keeps Agent Studio's starters. Load a newer SDK to get page starters.`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
43
55
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
56
|
+
* Keep the bar's starters matched to the page. Call once from the bundle's
|
|
57
|
+
* entry point; safe to call again.
|
|
46
58
|
*
|
|
47
59
|
* `setConversationStarters` REPLACES the whole set for this page view (both
|
|
48
|
-
* groups)
|
|
49
|
-
*
|
|
60
|
+
* groups) and also sets the panel's opening screen; `null` restores what
|
|
61
|
+
* Agent Studio has.
|
|
50
62
|
*/
|
|
51
63
|
export function installBottomBar(): void {
|
|
52
64
|
const foldspace = (window as any).foldspace;
|
|
@@ -57,7 +69,12 @@ export function installBottomBar(): void {
|
|
|
57
69
|
foldspace("when", "ready", () => {
|
|
58
70
|
const agent = getAgent();
|
|
59
71
|
if (!agent) return;
|
|
60
|
-
|
|
72
|
+
if (typeof agent.setConversationStarters !== "function") {
|
|
73
|
+
warnOnce("setConversationStarters");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// Agent Studio's starters are what the page starts with.
|
|
77
|
+
let current: PageStarters | null = null;
|
|
61
78
|
|
|
62
79
|
// On a navigation, the bar is brought back to full only when its starters
|
|
63
80
|
// changed, so the new ones are seen. Same starters: nothing is sent, and a
|
|
@@ -66,19 +83,40 @@ export function installBottomBar(): void {
|
|
|
66
83
|
const next = startersForPath(window.location.pathname);
|
|
67
84
|
if (next === current) return;
|
|
68
85
|
current = next;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
86
|
+
if (next) {
|
|
87
|
+
agent.setConversationStarters(
|
|
88
|
+
{
|
|
89
|
+
KNOWLEDGE: next.KNOWLEDGE.map((title) => ({ title })),
|
|
90
|
+
ACTION: next.ACTION.map((title) => ({ title })),
|
|
91
|
+
},
|
|
92
|
+
// The group shown first. Not documented: which group the bar itself
|
|
93
|
+
// shows when a page has both - check it on the page.
|
|
94
|
+
next.ACTION.length ? "ACTION" : "KNOWLEDGE",
|
|
95
|
+
);
|
|
96
|
+
} else {
|
|
97
|
+
agent.setConversationStarters(null);
|
|
98
|
+
}
|
|
99
|
+
if (!reopen) return;
|
|
100
|
+
if (typeof agent.openBottomBar === "function") agent.openBottomBar({ mode: "FULL" });
|
|
101
|
+
else warnOnce("openBottomBar");
|
|
74
102
|
};
|
|
75
103
|
|
|
76
|
-
// One whole settings group per call, as the docs do.
|
|
77
|
-
agent.setConfiguration?.({ bottomBarSettings: BOTTOM_BAR_SETTINGS });
|
|
78
104
|
apply(false);
|
|
79
105
|
|
|
80
|
-
// Single-page apps change the
|
|
106
|
+
// Single-page apps change the address without a page load: re-apply on
|
|
107
|
+
// each navigation. The browser's own page-change event where it has one:
|
|
108
|
+
// `currententrychange` fires once the address has changed - router links,
|
|
109
|
+
// pushState, replaceState, back and forward. (`navigate` fires before the
|
|
110
|
+
// address changes, so the page it would read is the old one.)
|
|
81
111
|
const onRouteChange = () => apply(true);
|
|
112
|
+
const navigation = (window as any).navigation;
|
|
113
|
+
if (navigation && typeof navigation.addEventListener === "function") {
|
|
114
|
+
navigation.addEventListener("currententrychange", onRouteChange);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
// Browsers without the Navigation API (some Safari and Firefox versions;
|
|
118
|
+
// not tried live): listen for back/forward and wrap pushState /
|
|
119
|
+
// replaceState.
|
|
82
120
|
window.addEventListener("popstate", onRouteChange);
|
|
83
121
|
const history = window.history as any;
|
|
84
122
|
for (const method of ["pushState", "replaceState"] as const) {
|
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
{
|
|
2
|
-
"note": "This recipe makes no network calls. What it sends to the SDK on
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"reopenFrequency": "EVERY_LOAD",
|
|
8
|
-
"starterClickBehavior": "OPEN_AGENT",
|
|
9
|
-
"idleTimeoutMs": 5000,
|
|
10
|
-
"initialBehaviorMode": "FULL"
|
|
2
|
+
"note": "This recipe makes no network calls and sends no bottomBarSettings (Foldspace turns the bar on). What it sends to the SDK on each page:",
|
|
3
|
+
"/dashboard": {
|
|
4
|
+
"setConversationStarters": {
|
|
5
|
+
"starters": { "KNOWLEDGE": [], "ACTION": [{ "title": "<Something an action shows about this screen>" }] },
|
|
6
|
+
"defaultStarterType": "ACTION"
|
|
11
7
|
}
|
|
12
8
|
},
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
|
|
9
|
+
"/<records>/42": {
|
|
10
|
+
"setConversationStarters": {
|
|
11
|
+
"starters": { "KNOWLEDGE": [{ "title": "<A how-to question about this screen>" }], "ACTION": [{ "title": "Find <a record> by name" }] },
|
|
12
|
+
"defaultStarterType": "ACTION"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"a page with no starters of its own, after one that had them": {
|
|
16
|
+
"setConversationStarters": { "starters": null }
|
|
16
17
|
}
|
|
17
18
|
}
|
|
@@ -31,7 +31,7 @@ says). If you observed neither, leave the user anonymous.
|
|
|
31
31
|
|---|---|
|
|
32
32
|
| `SESSION_STORAGE_KEY`, `SESSION_HEADER` | The key and header name you **observed** |
|
|
33
33
|
| `EXTRA_HEADERS` in the cookies variant | What you **observed** the app sending besides the cookies; most apps send none |
|
|
34
|
-
| `API_BASE` in `constants.ts` | For a same-origin app, the
|
|
34
|
+
| `API_BASE` in `constants.ts` | For a same-origin app, `"/api"` (the path), so each user's page calls its own host - on a product with a subdomain per customer, never the address you observed (the manual's **Each customer on their own subdomain**). Empty stays a failure on purpose, so a guessed host cannot look like success. Both files build their URLs with `apiBaseUrl()`, never `API_BASE` directly |
|
|
35
35
|
|
|
36
36
|
## What those builds learned the hard way
|
|
37
37
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Only the transport is replaced. What a 401, a 404 or a 429 means still comes
|
|
5
5
|
// from the harness, so actions and renderFailure behave exactly the same.
|
|
6
6
|
|
|
7
|
-
import { configure, httpFailure, networkFailure, type ApiResult } from "@foldspace_npm/harness/runtime";
|
|
7
|
+
import { apiBaseUrl, configure, httpFailure, networkFailure, type ApiResult } from "@foldspace_npm/harness/runtime";
|
|
8
8
|
import { AGENT_API_NAME, API_BASE } from "./constants";
|
|
9
9
|
|
|
10
10
|
configure({ agentApiName: AGENT_API_NAME, apiBase: API_BASE });
|
|
@@ -27,13 +27,13 @@ export async function apiFetch<T = unknown>(
|
|
|
27
27
|
// Empty API_BASE is a failure on purpose: a guessed host cannot look like
|
|
28
28
|
// success. `error` is drawn on the card and read by the agent - it says what
|
|
29
29
|
// is true; the builder's hint goes in `detail`, which only the console sees.
|
|
30
|
-
if (!
|
|
30
|
+
if (!apiBaseUrl()) {
|
|
31
31
|
return {
|
|
32
32
|
ok: false,
|
|
33
33
|
status: 0,
|
|
34
34
|
reason: "config",
|
|
35
35
|
error: "This is not set up yet.",
|
|
36
|
-
detail: "API_BASE is not set
|
|
36
|
+
detail: "API_BASE is not set, or names a {subdomain} this page does not have.",
|
|
37
37
|
};
|
|
38
38
|
}
|
|
39
39
|
const session = readSession();
|
|
@@ -42,7 +42,7 @@ export async function apiFetch<T = unknown>(
|
|
|
42
42
|
const controller = new AbortController();
|
|
43
43
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
44
44
|
try {
|
|
45
|
-
const res = await fetch(`${
|
|
45
|
+
const res = await fetch(`${apiBaseUrl()}${path}`, {
|
|
46
46
|
...init,
|
|
47
47
|
signal: controller.signal,
|
|
48
48
|
headers: { "Content-Type": "application/json", [SESSION_HEADER]: session, ...(init.headers ?? {}) },
|
|
@@ -69,13 +69,13 @@ export async function apiFetchBinary(
|
|
|
69
69
|
init: RequestInit = {},
|
|
70
70
|
timeoutMs = 15_000,
|
|
71
71
|
): Promise<ApiResult<Uint8Array>> {
|
|
72
|
-
if (!
|
|
72
|
+
if (!apiBaseUrl()) {
|
|
73
73
|
return {
|
|
74
74
|
ok: false,
|
|
75
75
|
status: 0,
|
|
76
76
|
reason: "config",
|
|
77
77
|
error: "This is not set up yet.",
|
|
78
|
-
detail: "API_BASE is not set
|
|
78
|
+
detail: "API_BASE is not set, or names a {subdomain} this page does not have.",
|
|
79
79
|
};
|
|
80
80
|
}
|
|
81
81
|
const session = readSession();
|
|
@@ -84,7 +84,7 @@ export async function apiFetchBinary(
|
|
|
84
84
|
const controller = new AbortController();
|
|
85
85
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
86
86
|
try {
|
|
87
|
-
const res = await fetch(`${
|
|
87
|
+
const res = await fetch(`${apiBaseUrl()}${path}`, {
|
|
88
88
|
...init,
|
|
89
89
|
signal: controller.signal,
|
|
90
90
|
headers: { [SESSION_HEADER]: session, ...(init.headers ?? {}) },
|
|
@@ -103,6 +103,7 @@ export async function apiFetchBinary(
|
|
|
103
103
|
// the harness's returns a config failure here, because this file sets no
|
|
104
104
|
// `authSource`. For the same reason getAuthToken returns null (see the README).
|
|
105
105
|
export {
|
|
106
|
+
apiBaseUrl,
|
|
106
107
|
getAgent,
|
|
107
108
|
armAllInstances,
|
|
108
109
|
rankBy,
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// There is nothing to check before the request, so "signed out" is only known
|
|
5
5
|
// from the 401. It comes back as `signed_out` like any other.
|
|
6
6
|
|
|
7
|
-
import { configure, httpFailure, networkFailure, type ApiResult } from "@foldspace_npm/harness/runtime";
|
|
7
|
+
import { apiBaseUrl, configure, httpFailure, networkFailure, type ApiResult } from "@foldspace_npm/harness/runtime";
|
|
8
8
|
import { AGENT_API_NAME, API_BASE } from "./constants";
|
|
9
9
|
|
|
10
10
|
configure({ agentApiName: AGENT_API_NAME, apiBase: API_BASE });
|
|
@@ -24,20 +24,20 @@ export async function apiFetch<T = unknown>(
|
|
|
24
24
|
// Empty API_BASE is a failure on purpose: a guessed host cannot look like
|
|
25
25
|
// success. `error` is drawn on the card and read by the agent - it says what
|
|
26
26
|
// is true; the builder's hint goes in `detail`, which only the console sees.
|
|
27
|
-
if (!
|
|
27
|
+
if (!apiBaseUrl()) {
|
|
28
28
|
return {
|
|
29
29
|
ok: false,
|
|
30
30
|
status: 0,
|
|
31
31
|
reason: "config",
|
|
32
32
|
error: "This is not set up yet.",
|
|
33
|
-
detail: "API_BASE is not set
|
|
33
|
+
detail: "API_BASE is not set, or names a {subdomain} this page does not have.",
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
const controller = new AbortController();
|
|
38
38
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
39
39
|
try {
|
|
40
|
-
const res = await fetch(`${
|
|
40
|
+
const res = await fetch(`${apiBaseUrl()}${path}`, {
|
|
41
41
|
...init,
|
|
42
42
|
credentials: "include",
|
|
43
43
|
signal: controller.signal,
|
|
@@ -65,20 +65,20 @@ export async function apiFetchBinary(
|
|
|
65
65
|
init: RequestInit = {},
|
|
66
66
|
timeoutMs = 15_000,
|
|
67
67
|
): Promise<ApiResult<Uint8Array>> {
|
|
68
|
-
if (!
|
|
68
|
+
if (!apiBaseUrl()) {
|
|
69
69
|
return {
|
|
70
70
|
ok: false,
|
|
71
71
|
status: 0,
|
|
72
72
|
reason: "config",
|
|
73
73
|
error: "This is not set up yet.",
|
|
74
|
-
detail: "API_BASE is not set
|
|
74
|
+
detail: "API_BASE is not set, or names a {subdomain} this page does not have.",
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
const controller = new AbortController();
|
|
79
79
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
80
80
|
try {
|
|
81
|
-
const res = await fetch(`${
|
|
81
|
+
const res = await fetch(`${apiBaseUrl()}${path}`, {
|
|
82
82
|
...init,
|
|
83
83
|
credentials: "include",
|
|
84
84
|
signal: controller.signal,
|
|
@@ -98,6 +98,7 @@ export async function apiFetchBinary(
|
|
|
98
98
|
// the harness's returns a config failure here, because this file sets no
|
|
99
99
|
// `authSource`. For the same reason getAuthToken returns null (see the README).
|
|
100
100
|
export {
|
|
101
|
+
apiBaseUrl,
|
|
101
102
|
getAgent,
|
|
102
103
|
armAllInstances,
|
|
103
104
|
rankBy,
|
package/src/chrome-binary.mjs
CHANGED
|
@@ -26,3 +26,36 @@ export function chromeCandidates(platform = os.platform(), env = process.env) {
|
|
|
26
26
|
export function findChrome({ env = process.env, platform = os.platform(), exists = fs.existsSync } = {}) {
|
|
27
27
|
return env.CHROME_PATH || chromeCandidates(platform, env).find((candidate) => exists(candidate)) || null;
|
|
28
28
|
}
|
|
29
|
+
|
|
30
|
+
// Chrome slows a page whose window is hidden, covered or minimized - timers
|
|
31
|
+
// throttled, the renderer moved to the background - and the build in it stalls.
|
|
32
|
+
// These keep the test window working while the builder looks at another app
|
|
33
|
+
// or another macOS user's session (⚠️ not tried live with a user switch).
|
|
34
|
+
// The hidden Chrome that makes the video uses them too.
|
|
35
|
+
export const KEEP_RUNNING_ARGS = Object.freeze([
|
|
36
|
+
"--disable-background-timer-throttling",
|
|
37
|
+
"--disable-renderer-backgrounding",
|
|
38
|
+
"--disable-backgrounding-occluded-windows",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/** How `inject` starts the test window. */
|
|
42
|
+
export function testWindowArgs({ profileDir, debugPort, themeDir, startUrl }) {
|
|
43
|
+
return [
|
|
44
|
+
`--user-data-dir=${profileDir}`,
|
|
45
|
+
`--remote-debugging-port=${debugPort}`,
|
|
46
|
+
// The theme only paints the isolated window Foldspace blue. SDK and action
|
|
47
|
+
// code are loaded later by attach over CDP.
|
|
48
|
+
`--load-extension=${themeDir}`,
|
|
49
|
+
`--disable-extensions-except=${themeDir}`,
|
|
50
|
+
// Chrome 136+ dropped --load-extension. Keep the legacy switch for older
|
|
51
|
+
// builds, and enable the CDP Extensions domain so we can load it at runtime.
|
|
52
|
+
"--disable-features=DisableLoadExtensionCommandLineSwitch",
|
|
53
|
+
"--enable-unsafe-extension-debugging",
|
|
54
|
+
"--no-first-run",
|
|
55
|
+
"--no-default-browser-check",
|
|
56
|
+
"--test-type",
|
|
57
|
+
"--remote-allow-origins=*",
|
|
58
|
+
...KEEP_RUNNING_ARGS,
|
|
59
|
+
startUrl,
|
|
60
|
+
];
|
|
61
|
+
}
|
package/src/cli-registry.mjs
CHANGED
|
@@ -76,7 +76,7 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
76
76
|
name: "upgrade",
|
|
77
77
|
entry: null,
|
|
78
78
|
group: "start",
|
|
79
|
-
summary: "Check for a newer harness and
|
|
79
|
+
summary: "Check for a newer harness and update the version this project uses",
|
|
80
80
|
usage:
|
|
81
81
|
"foldspace upgrade [--check | --yes] [--refresh-instructions]",
|
|
82
82
|
risk: "local-write",
|
|
@@ -91,7 +91,7 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
91
91
|
flag("--check", "Compare versions and print JSON; never install"),
|
|
92
92
|
flag(
|
|
93
93
|
"--yes",
|
|
94
|
-
"Install the latest version after the human has agreed",
|
|
94
|
+
"Install the latest version: within the same line (update.autoUpgrade) without asking; across a line, after the human has agreed",
|
|
95
95
|
),
|
|
96
96
|
flag(
|
|
97
97
|
"--refresh-instructions",
|
|
@@ -111,11 +111,56 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
111
111
|
"Any foldspace command writes a harness skill the project is missing, so the first upgrade from a harness older than the skill still gets it",
|
|
112
112
|
],
|
|
113
113
|
next: [
|
|
114
|
-
"If
|
|
114
|
+
"If update.autoUpgrade (a newer version in the same line, such as 0.1.x): run npx foldspace upgrade --yes without asking, and tell the human in one plain line",
|
|
115
|
+
"If outdated across a line (0.1 to 0.2): ask the human, then run npx foldspace upgrade --yes",
|
|
115
116
|
"Run npm run build after an upgrade",
|
|
116
117
|
"If attach is running: `npx foldspace attach --stop`, then `npx foldspace attach --daemon` with the flags it ran with, to load the rebuild",
|
|
117
118
|
],
|
|
118
119
|
}),
|
|
120
|
+
Object.freeze({
|
|
121
|
+
name: "sync",
|
|
122
|
+
entry: "sync.mjs",
|
|
123
|
+
group: "start",
|
|
124
|
+
summary: "Keep agent/ up to date with the agent's saved code: what to pull, then merge it in",
|
|
125
|
+
usage:
|
|
126
|
+
"foldspace sync <check|apply|saved> [--develop <sha>] [--line <sha>] [--head <sha>] [--paths <a,b>]",
|
|
127
|
+
risk: "local-write",
|
|
128
|
+
environment: "node",
|
|
129
|
+
environmentVariables: ["FOLDSPACE_PROJECT_DIR"],
|
|
130
|
+
capabilities: ["project.sync"],
|
|
131
|
+
positionals: [
|
|
132
|
+
Object.freeze({
|
|
133
|
+
name: "verb",
|
|
134
|
+
required: true,
|
|
135
|
+
description:
|
|
136
|
+
"check: compare the heads you just read with what this folder last pulled, and say what to pull | apply: merge what you pulled into agent/ | saved: remember a save this folder made",
|
|
137
|
+
}),
|
|
138
|
+
],
|
|
139
|
+
options: [
|
|
140
|
+
value("--develop", "sha", "check: develop_head from repo_read status"),
|
|
141
|
+
value("--line", "sha", "check: the head of foldspace/<Agent Key>, the line of work saves go to (the sha repo_read tree prints for that ref); leave it out while that line does not exist"),
|
|
142
|
+
value("--head", "sha", "saved: the commit_sha the save returned"),
|
|
143
|
+
value("--paths", "a,b", "saved: the committed_paths the save returned, comma-separated"),
|
|
144
|
+
],
|
|
145
|
+
prerequisites: [
|
|
146
|
+
"foldspace.dev.json",
|
|
147
|
+
"A code project (repo_read status), read with the Foldspace tools: this command never reaches Foldspace itself",
|
|
148
|
+
],
|
|
149
|
+
effects: [
|
|
150
|
+
"Writes only under .foldspace-dev/ (saved-code/ and agent-kept/) and, for apply, agent/",
|
|
151
|
+
"Changes nothing about saving or publishing: those follow repo_read command instructions",
|
|
152
|
+
"check names each ref to pull and the folder to write it into (pull[].into); it pulls nothing itself",
|
|
153
|
+
"apply copies agent/ to .foldspace-dev/agent-kept/<time>/ first, then merges file by file against what this folder last pulled: a file only one side changed takes that side; one both changed takes the saved version and is listed in conflicts",
|
|
154
|
+
"On a first pull (nothing remembered), the saved code wins every difference, and of the files it lacks only agent/actions/, agent/api/ and agent/views/ go back",
|
|
155
|
+
"agent/runtime/ is the build's: differences there are never conflicts",
|
|
156
|
+
],
|
|
157
|
+
next: [
|
|
158
|
+
"check with pull: read agent/ at each ref into its into folder (repo_read tree and file), then npx foldspace sync apply",
|
|
159
|
+
"check up to date: save as repo_read command instructions says, with base_sha = baseSha",
|
|
160
|
+
"apply: follow its next - conflicts mean putting this session's change back on top; then npm run build",
|
|
161
|
+
"After each save: npx foldspace sync saved with the commit_sha and committed_paths it returned",
|
|
162
|
+
],
|
|
163
|
+
}),
|
|
119
164
|
Object.freeze({
|
|
120
165
|
name: "build",
|
|
121
166
|
entry: "build-cli.mjs",
|
|
@@ -577,6 +622,7 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
577
622
|
export const CAPABILITY_CATALOGUE = Object.freeze([
|
|
578
623
|
["project.scaffold", "Create a new configured project on local disk"],
|
|
579
624
|
["project.upgrade", "Update the harness version the consuming project uses"],
|
|
625
|
+
["project.sync", "Merge the agent's saved code, read with the Foldspace tools, into agent/"],
|
|
580
626
|
["artifact.build", "Build the portable dist/index.js action artifact"],
|
|
581
627
|
["browser.launch", "Launch an isolated local Chrome profile"],
|
|
582
628
|
["browser.cdp", "Connect to local Chrome through CDP"],
|
|
@@ -656,9 +702,10 @@ export function createCliRegistry({ packageName, packageVersion, env = process.e
|
|
|
656
702
|
"The harness bin alias is equivalent to foldspace.",
|
|
657
703
|
"attach diagnostics are attach-internal; interpret them from the lifecycle log, not as CLI commands.",
|
|
658
704
|
"Coding agents run attach --daemon; foreground attach is for someone watching the terminal.",
|
|
659
|
-
"If update.
|
|
660
|
-
"If update.broken is true, this version was withdrawn and build and attach refuse to run.
|
|
705
|
+
"If update.autoUpgrade is true (a newer version in the same line), run npx foldspace upgrade --yes without asking and tell the human in one plain line; if update.outdated is true across a line, ask the human first.",
|
|
706
|
+
"If update.broken is true, this version was withdrawn and build and attach refuse to run. Within the same line, upgrade without asking; across a line, ask the human to agree to the upgrade, and set FOLDSPACE_SKIP_UPDATE_CHECK=1 only if they decline.",
|
|
661
707
|
"observe looks at the app, and ask tests the agent through its panel; both work while attach runs.",
|
|
708
|
+
"With a code project, pull the saved code with `npx foldspace sync` at session start, before each new experience and before each save or publish (manual: \"Several builders, one agent\"); saving and publishing follow repo_read command instructions.",
|
|
662
709
|
"`foldspace deploy` is for Foldspace staff only: never run or offer it - it is not the `deploy` tool. With a code project, putting the agent's code in front of their users is yours, as the manual's \"Done, and what you say at the end\" says: save with `repo_write`; publish to dev with the `deploy` tool (say you are doing it, then do it - unless its dev preview comes back with no warnings, which means this agent loads dev for its users: then ask first, as for production); check dev with `npx foldspace attach --stop`, `npx foldspace published dev` and `npx foldspace ask \"<question>\" --retry 2`; and publish to production only once that check passed and the human says yes. The dev check and production need a site that loads the agent by itself: after `attach --bootstrap` or `--replace`, publish to dev only, keep attach running, and the rest is a step the Foldspace team does with the human. If `published` says this window cannot show dev, say so in one sentence, attach again, and stop after dev. Without a code project, or when a Foldspace tool refuses (except a save refused because someone saved after you read: redo it as `repo_read` command `instructions` says), the Foldspace team does it with the human (https://foldspace.ai/demo, support@foldspace.ai).",
|
|
663
710
|
],
|
|
664
711
|
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// A product that gives each customer their own subdomain - acme.example.com,
|
|
2
|
+
// globex.example.com - must never ship one customer's address: the agent is
|
|
3
|
+
// built signed in to one customer, and every user of every customer would get
|
|
4
|
+
// the code. This module is the one place that decides what "looks like one
|
|
5
|
+
// customer's subdomain" means, for `observe` (which asks the builder) and for
|
|
6
|
+
// lint (which refuses the fixed address). Pure: no network, no filesystem.
|
|
7
|
+
|
|
8
|
+
import { siteOf } from "./observe-core.mjs";
|
|
9
|
+
|
|
10
|
+
export const SUBDOMAIN_PLACEHOLDER = "{subdomain}";
|
|
11
|
+
|
|
12
|
+
// First parts of an address that name a part of the product, a region or an
|
|
13
|
+
// environment - not a customer. Wrong in either direction costs little: a
|
|
14
|
+
// miss here only means the builder is asked one question too many.
|
|
15
|
+
const GENERIC_LABELS = new Set([
|
|
16
|
+
"app", "apps", "api", "apis", "www", "web", "my", "portal", "dashboard", "admin", "console",
|
|
17
|
+
"secure", "login", "auth", "accounts", "account", "id", "sso", "oauth", "cloud", "platform",
|
|
18
|
+
"go", "dev", "develop", "staging", "stage", "test", "qa", "uat", "sandbox", "demo", "beta",
|
|
19
|
+
"preview", "prod", "production", "live", "global", "m", "mobile", "mail", "help", "support",
|
|
20
|
+
"docs", "status", "cdn", "static", "assets", "media", "files", "home", "client", "clients",
|
|
21
|
+
"members", "manage", "manager", "internal", "backoffice", "ops", "studio", "hub", "workspace",
|
|
22
|
+
"office", "online", "service", "services", "gateway", "data", "reports", "reporting",
|
|
23
|
+
"analytics", "insights", "billing", "pay", "payments", "shop", "store", "events", "connect",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
// app2, app-eu1, api-staging, eu, us-east-1, na3 ...
|
|
27
|
+
const GENERIC_PATTERN =
|
|
28
|
+
/^(?:app|api|web|www|eu|us|na|ap|uk|au|ca|de|fr|jp|in|sg|emea|apac|latam)(?:[-_]?[a-z]*\d+|(?:[-_][a-z]+)+(?:[-_]?\d+)?)?$/;
|
|
29
|
+
|
|
30
|
+
export function isGenericLabel(label) {
|
|
31
|
+
const value = String(label || "").toLowerCase();
|
|
32
|
+
return GENERIC_LABELS.has(value) || GENERIC_PATTERN.test(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function hostOf(value) {
|
|
36
|
+
const text = String(value || "").trim().toLowerCase();
|
|
37
|
+
if (!text) return "";
|
|
38
|
+
try {
|
|
39
|
+
return new URL(/^[a-z][a-z0-9+.-]*:\/\//.test(text) ? text : `https://${text}`).hostname.replace(/\.$/, "");
|
|
40
|
+
} catch {
|
|
41
|
+
return "";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The first part of a host in front of the product's own domain (`acme` in acme.example.com), or null. A leading `www.` is skipped. */
|
|
46
|
+
export function leftmostLabel(hostOrUrl) {
|
|
47
|
+
const host = hostOf(hostOrUrl);
|
|
48
|
+
if (!host || /^[\d.]+$/.test(host) || host === "localhost" || host.endsWith(".localhost")) return null;
|
|
49
|
+
const labels = host.split(".");
|
|
50
|
+
if (labels[0] === "www") labels.shift();
|
|
51
|
+
const siteLabels = siteOf(labels.join(".")).split(".").length;
|
|
52
|
+
return labels.length > siteLabels ? labels[0] : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The customer label to watch for in addresses, given the app's own host and
|
|
57
|
+
* what the project says (`PER_CUSTOMER_SUBDOMAIN`): `false` - none; `true` -
|
|
58
|
+
* the first part of the host, whatever it is; undecided - the first part only
|
|
59
|
+
* when it does not read as a part of the product, a region or an environment.
|
|
60
|
+
*/
|
|
61
|
+
export function customerLabel(appHost, decided) {
|
|
62
|
+
if (decided === false) return null;
|
|
63
|
+
const label = leftmostLabel(appHost);
|
|
64
|
+
if (!label) return null;
|
|
65
|
+
if (decided === true) return label;
|
|
66
|
+
return isGenericLabel(label) ? null : label;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Whether an address names that customer: a part of its host, or a segment of its path. `{subdomain}` never counts. */
|
|
70
|
+
export function addressNamesLabel(url, label) {
|
|
71
|
+
if (!label) return false;
|
|
72
|
+
const want = label.toLowerCase();
|
|
73
|
+
let parsed;
|
|
74
|
+
try {
|
|
75
|
+
parsed = new URL(String(url).replace(/\{subdomain\}/g, "x-subdomain-x"));
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
const parts = (text) => text.toLowerCase().split(/[./]/).filter(Boolean);
|
|
80
|
+
const named = (part) => part === want || part.split(/[-_]/).includes(want);
|
|
81
|
+
return parts(parsed.hostname).some(named) || parts(decodeURIComponent(parsed.pathname)).some(named);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* What API_BASE should say instead of a fixed address that names one
|
|
86
|
+
* customer: the path alone when the backend is on the app's own host,
|
|
87
|
+
* otherwise the address with `{subdomain}` where the customer was.
|
|
88
|
+
*/
|
|
89
|
+
export function suggestedApiBase(url, appHost, label) {
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = new URL(url);
|
|
93
|
+
} catch {
|
|
94
|
+
return `/api`;
|
|
95
|
+
}
|
|
96
|
+
const path = parsed.pathname.replace(/\/$/, "");
|
|
97
|
+
if (parsed.hostname.toLowerCase() === hostOf(appHost)) return path || "/";
|
|
98
|
+
const swap = (part) =>
|
|
99
|
+
part.toLowerCase() === label.toLowerCase()
|
|
100
|
+
? SUBDOMAIN_PLACEHOLDER
|
|
101
|
+
: part
|
|
102
|
+
.split("-")
|
|
103
|
+
.map((piece) => (piece.toLowerCase() === label.toLowerCase() ? SUBDOMAIN_PLACEHOLDER : piece))
|
|
104
|
+
.join("-");
|
|
105
|
+
const host = parsed.hostname.split(".").map(swap).join(".");
|
|
106
|
+
const swappedPath = path.split("/").map(swap).join("/");
|
|
107
|
+
return `${parsed.protocol}//${host}${parsed.port ? `:${parsed.port}` : ""}${swappedPath}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** `PER_CUSTOMER_SUBDOMAIN` as written in agent/constants.ts: true, false, or undefined while undecided. */
|
|
111
|
+
export function readCustomerSubdomainSetting(constantsText) {
|
|
112
|
+
const match = /export\s+const\s+PER_CUSTOMER_SUBDOMAIN\b[^=]*=\s*(true|false)\b/.exec(String(constantsText || ""));
|
|
113
|
+
return match ? match[1] === "true" : undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* For `observe`: when the page's address looks like one customer's own
|
|
118
|
+
* subdomain and the project has not settled it, say so and what to ask.
|
|
119
|
+
* `requestUrls` are the calls the page made; one that names the same
|
|
120
|
+
* customer is the strongest sign, and is reported.
|
|
121
|
+
*/
|
|
122
|
+
export function customerSubdomainHint(pageUrl, requestUrls = [], decided) {
|
|
123
|
+
if (decided !== undefined) return null;
|
|
124
|
+
const pageHost = hostOf(pageUrl);
|
|
125
|
+
const label = customerLabel(pageHost, undefined);
|
|
126
|
+
if (!label) return null;
|
|
127
|
+
const seenIn = [];
|
|
128
|
+
for (const url of requestUrls) {
|
|
129
|
+
let host;
|
|
130
|
+
try {
|
|
131
|
+
host = new URL(url).hostname.toLowerCase();
|
|
132
|
+
} catch {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (addressNamesLabel(url, label) && !seenIn.includes(host)) seenIn.push(host);
|
|
136
|
+
if (seenIn.length >= 4) break;
|
|
137
|
+
}
|
|
138
|
+
const site = siteOf(pageHost.replace(/^www\./, ""));
|
|
139
|
+
return {
|
|
140
|
+
label,
|
|
141
|
+
pageHost,
|
|
142
|
+
...(seenIn.length ? { backendHosts: seenIn } : {}),
|
|
143
|
+
hint:
|
|
144
|
+
`The address starts with "${label}." - that may be one customer's own subdomain, with other customers on other.${site}. ` +
|
|
145
|
+
`Ask the human once, as a choice: does each of their customers get their own address like ${pageHost}? ` +
|
|
146
|
+
`Yes: set PER_CUSTOMER_SUBDOMAIN = true in agent/constants.ts and write API_BASE from the page's address ("/api", or "https://{subdomain}.…") - never the address you observed. ` +
|
|
147
|
+
`No: set PER_CUSTOMER_SUBDOMAIN = false.`,
|
|
148
|
+
};
|
|
149
|
+
}
|