@foldspace_npm/harness 0.1.10 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CLAUDE.md +328 -0
  2. package/README.md +24 -5
  3. package/bin/cli.mjs +29 -1
  4. package/package.json +4 -2
  5. package/recipes/INDEX.md +15 -0
  6. package/recipes/README.md +46 -0
  7. package/recipes/find-by-name/README.md +47 -0
  8. package/recipes/find-by-name/agent/actions/find_project_id.ts +101 -0
  9. package/recipes/find-by-name/agent/api/projects.ts +36 -0
  10. package/recipes/find-by-name/agent/projects.ts +40 -0
  11. package/recipes/find-by-name/fixtures/projects.all.json +29 -0
  12. package/recipes/find-by-name/fixtures/projects.empty-account.json +4 -0
  13. package/recipes/find-by-name/fixtures/projects.none.json +4 -0
  14. package/recipes/find-by-name/recipe.json +10 -0
  15. package/recipes/pick-from-a-list/README.md +44 -0
  16. package/recipes/pick-from-a-list/agent/actions/choose_project.ts +161 -0
  17. package/recipes/pick-from-a-list/agent/api/projects.ts +36 -0
  18. package/recipes/pick-from-a-list/agent/projects.ts +40 -0
  19. package/recipes/pick-from-a-list/agent/views/brand.ts +14 -0
  20. package/recipes/pick-from-a-list/agent/views/picker.ts +119 -0
  21. package/recipes/pick-from-a-list/fixtures/projects.all.json +29 -0
  22. package/recipes/pick-from-a-list/fixtures/projects.empty-account.json +4 -0
  23. package/recipes/pick-from-a-list/recipe.json +10 -0
  24. package/recipes/swap-the-login-method/README.md +40 -0
  25. package/recipes/swap-the-login-method/agent/utils.ts +73 -0
  26. package/recipes/swap-the-login-method/fixtures/anything.ok.json +8 -0
  27. package/recipes/swap-the-login-method/recipe.json +12 -0
  28. package/recipes/swap-the-login-method/variants/utils.cookies.ts +64 -0
  29. package/recipes/who-is-the-user/README.md +56 -0
  30. package/recipes/who-is-the-user/agent/identify.ts +89 -0
  31. package/recipes/who-is-the-user/fixtures/profile.ok.json +6 -0
  32. package/recipes/who-is-the-user/recipe.json +9 -0
  33. package/src/cli-help.mjs +1 -0
  34. package/src/cli-registry.mjs +48 -3
  35. package/src/init.mjs +16 -8
  36. package/src/runtime/config.ts +1 -1
  37. package/src/runtime/http.ts +104 -53
  38. package/src/runtime/index.ts +4 -1
  39. package/src/runtime/match.ts +1 -1
  40. package/src/runtime/render.ts +42 -1
  41. package/src/upgrade.mjs +469 -0
  42. package/templates/agent-starter/CLAUDE.md +11 -220
  43. package/templates/agent-starter/README.md +1 -1
  44. package/templates/agent-starter/agent/actions/_example.ts +9 -2
  45. package/templates/agent-starter/agent/utils.ts +2 -0
@@ -0,0 +1,4 @@
1
+ {
2
+ "items": [],
3
+ "total": 0
4
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "title": "Pick from a list",
3
+ "level": "L2",
4
+ "family": "ask-the-user",
5
+ "kind": "action",
6
+ "action": "choose_project",
7
+ "entry": "agent/actions/choose_project.ts",
8
+ "outcome": "A clickable list, shown only when the user has to choose",
9
+ "provenBy": 1
10
+ }
@@ -0,0 +1,40 @@
1
+ # Swap the login method
2
+
3
+ The harness's `apiFetch` sends `Authorization: Bearer <token>`. That fits some
4
+ apps. **Three production builds, three different schemes:**
5
+
6
+ | What the app's own requests carried | Builds | Use |
7
+ |---|---|---|
8
+ | `Authorization: Bearer`, token in a cookie or `localStorage`, plus a fixed API-key header | 1 | the default `apiFetch`, with the extra header passed in `init.headers` |
9
+ | A **custom header** holding a session id read from `localStorage` | 1 | `agent/utils.ts` here |
10
+ | **Cookies** — `credentials: "include"` — and no token at all | 1 | `variants/utils.cookies.ts` |
11
+
12
+ **Observe which one the app uses before choosing.** Read it off a request the
13
+ page already made. Never guess, and never reach for a public developer API
14
+ because the browser session was missing.
15
+
16
+ ## Adapt it
17
+
18
+ Copy the variant you need over your project's `agent/utils.ts`. Actions keep
19
+ importing from `../utils`, so nothing else changes — that is what the seam is
20
+ for.
21
+
22
+ | In the file | Change |
23
+ |---|---|
24
+ | `SESSION_STORAGE_KEY`, `SESSION_HEADER` | The key and header name you **observed** |
25
+ | The cookies variant's extra headers | Only what you observed the app sending. One build needed `x-requested-with`; most do not |
26
+ | `API_BASE` in `constants.ts` | For a same-origin app, the app's own origin. Empty stays a failure on purpose, so a guessed host cannot look like success |
27
+
28
+ ## What those builds learned the hard way
29
+
30
+ - **Keep the failure reasons.** Replace the transport, not what a 401 or a 404
31
+ means: `httpFailure` and `networkFailure` are exported for exactly this, so a
32
+ custom `apiFetch` still returns `signed_out`, `not_found`, `rate_limited`.
33
+ Hand-rolled versions turned every failure into "unexpected error", or a
34
+ missing session into an empty list.
35
+ - **Tokens are stored three ways** — raw, JSON-encoded (wrapped in quotes), or
36
+ inside an object. Strip the quotes; one app switched between the first two.
37
+ - **With cookies there is no token to check first**, so "signed out" is only
38
+ known from the 401. Let it through as `signed_out` instead of pre-empting it.
39
+ - **Do not `export *`** alongside your own `apiFetch` — name what you re-export,
40
+ so the default cannot come back by accident.
@@ -0,0 +1,73 @@
1
+ // agent/utils.ts for an app that authenticates with a CUSTOM HEADER holding a
2
+ // session id from localStorage — not `Authorization: Bearer`.
3
+ //
4
+ // Only the transport is replaced. What a 401, a 404 or a 429 means still comes
5
+ // from the harness, so actions and renderFailure behave exactly the same.
6
+
7
+ import { configure, httpFailure, networkFailure, type ApiResult } from "@foldspace_npm/harness/runtime";
8
+ import { AGENT_API_NAME, API_BASE } from "./constants";
9
+
10
+ configure({ agentApiName: AGENT_API_NAME, apiBase: API_BASE });
11
+
12
+ /** The localStorage key and the header name you OBSERVED on a real request. */
13
+ const SESSION_STORAGE_KEY = "__observe_me_session";
14
+ const SESSION_HEADER = "__observe-me-session";
15
+
16
+ function readSession(): string | null {
17
+ const raw = window.localStorage.getItem(SESSION_STORAGE_KEY);
18
+ // Apps store it raw or JSON-encoded; strip the quotes either way.
19
+ return raw ? raw.replace(/^"|"$/g, "") || null : null;
20
+ }
21
+
22
+ export async function apiFetch<T = unknown>(
23
+ path: string,
24
+ init: RequestInit = {},
25
+ timeoutMs = 15_000,
26
+ ): Promise<ApiResult<T>> {
27
+ if (!API_BASE) {
28
+ return { ok: false, status: 0, reason: "config", error: "API_BASE is not set — capture the app's XHR first." };
29
+ }
30
+ const session = readSession();
31
+ if (!session) return { ok: false, status: 401, reason: "signed_out", error: "Not signed in." };
32
+
33
+ const controller = new AbortController();
34
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
35
+ try {
36
+ const res = await fetch(`${API_BASE}${path}`, {
37
+ ...init,
38
+ signal: controller.signal,
39
+ headers: { "Content-Type": "application/json", [SESSION_HEADER]: session, ...(init.headers ?? {}) },
40
+ });
41
+ const text = await res.text();
42
+ let parsed: unknown = text;
43
+ try {
44
+ parsed = JSON.parse(text);
45
+ } catch {
46
+ /* non-JSON; keep the raw text */
47
+ }
48
+ if (!res.ok) return httpFailure(res, "That request could not be completed.");
49
+ return { ok: true, status: res.status, data: parsed as T };
50
+ } catch (error) {
51
+ return networkFailure(error, "Request timed out.", "Could not reach the server.");
52
+ } finally {
53
+ clearTimeout(timer);
54
+ }
55
+ }
56
+
57
+ // Everything else still comes from the harness. Named, NOT `export *` — that
58
+ // would put the default apiFetch back next to this one.
59
+ export {
60
+ getAgent,
61
+ armAllInstances,
62
+ rankBy,
63
+ redact,
64
+ parseJwt,
65
+ publicFetch,
66
+ mapWithConcurrency,
67
+ renderLoading,
68
+ renderEmpty,
69
+ renderError,
70
+ renderFatal,
71
+ renderFailure,
72
+ } from "@foldspace_npm/harness/runtime";
73
+ export type { ApiResult, ApiFailure, FailureReason, ViewHost } from "@foldspace_npm/harness/runtime";
@@ -0,0 +1,8 @@
1
+ {
2
+ "items": [
3
+ {
4
+ "id": "1",
5
+ "name": "Alpha"
6
+ }
7
+ ]
8
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "title": "Swap the login method",
3
+ "level": "any",
4
+ "family": "auth",
5
+ "kind": "override",
6
+ "entry": "agent/utils.ts",
7
+ "variants": [
8
+ "variants/utils.cookies.ts"
9
+ ],
10
+ "outcome": "apiFetch for an app that does not authenticate with a bearer token",
11
+ "provenBy": 2
12
+ }
@@ -0,0 +1,64 @@
1
+ // agent/utils.ts for an app that authenticates with COOKIES — no token to read.
2
+ // Copy this over agent/utils.ts.
3
+ //
4
+ // There is nothing to check before the request, so "signed out" is only known
5
+ // from the 401. It comes back as `signed_out` like any other.
6
+
7
+ import { configure, httpFailure, networkFailure, type ApiResult } from "@foldspace_npm/harness/runtime";
8
+ import { AGENT_API_NAME, API_BASE } from "./constants";
9
+
10
+ configure({ agentApiName: AGENT_API_NAME, apiBase: API_BASE });
11
+
12
+ export async function apiFetch<T = unknown>(
13
+ path: string,
14
+ init: RequestInit = {},
15
+ timeoutMs = 15_000,
16
+ ): Promise<ApiResult<T>> {
17
+ if (!API_BASE) {
18
+ return { ok: false, status: 0, reason: "config", error: "API_BASE is not set — capture the app's XHR first." };
19
+ }
20
+
21
+ const controller = new AbortController();
22
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
23
+ try {
24
+ const res = await fetch(`${API_BASE}${path}`, {
25
+ ...init,
26
+ credentials: "include",
27
+ signal: controller.signal,
28
+ // Add only what you OBSERVED the app sending. One build needed
29
+ // `x-requested-with: XMLHttpRequest`; most do not.
30
+ headers: { accept: "application/json", "Content-Type": "application/json", ...(init.headers ?? {}) },
31
+ });
32
+ const text = await res.text();
33
+ let parsed: unknown = text;
34
+ try {
35
+ parsed = JSON.parse(text);
36
+ } catch {
37
+ /* non-JSON; keep the raw text */
38
+ }
39
+ if (!res.ok) return httpFailure(res, "That request could not be completed.");
40
+ return { ok: true, status: res.status, data: parsed as T };
41
+ } catch (error) {
42
+ return networkFailure(error, "Request timed out.", "Could not reach the server.");
43
+ } finally {
44
+ clearTimeout(timer);
45
+ }
46
+ }
47
+
48
+ // Everything else still comes from the harness. Named, NOT `export *` — that
49
+ // would put the default apiFetch back next to this one.
50
+ export {
51
+ getAgent,
52
+ armAllInstances,
53
+ rankBy,
54
+ redact,
55
+ parseJwt,
56
+ publicFetch,
57
+ mapWithConcurrency,
58
+ renderLoading,
59
+ renderEmpty,
60
+ renderError,
61
+ renderFatal,
62
+ renderFailure,
63
+ } from "@foldspace_npm/harness/runtime";
64
+ export type { ApiResult, ApiFailure, FailureReason, ViewHost } from "@foldspace_npm/harness/runtime";
@@ -0,0 +1,56 @@
1
+ # Who is the user — L0
2
+
3
+ L0 is not "the agent appears". It is the agent appearing **and** Foldspace
4
+ knowing who it is talking to. Without `identify`, every conversation, every
5
+ analytics row and every segment is anonymous.
6
+
7
+ **Proven by 4 production builds**, which found the identity in three different
8
+ places — so look before you assume:
9
+
10
+ | Where the signed-in user was found | Builds |
11
+ |---|---|
12
+ | Claims inside the app's own session token (user id, email, organisation id) | 2 |
13
+ | A user object the app keeps in `localStorage` | 1 |
14
+ | A call to the app's own account endpoint, cached for the page | 1 |
15
+
16
+ ## Adapt it
17
+
18
+ | In `agent/identify.ts` | Change |
19
+ |---|---|
20
+ | `SessionClaims` | The claim names you **observed** in the token. Two builds, two spellings |
21
+ | `/__observe_me/profile` and `Profile` | The endpoint that describes **the signed-in user** — see the trap below |
22
+ | `subscription.id` | Whatever groups colleagues: organisation, account, workspace |
23
+
24
+ Then call `identifyUser()` from the bundle's entry point:
25
+
26
+ ```ts
27
+ // agent/actions/index.ts — last line
28
+ import { identifyUser } from "../identify";
29
+ identifyUser();
30
+ ```
31
+
32
+ Custom attributes (a plan name, a sign-up date) only land if they were created
33
+ first in **Settings → Attribute Settings**; send them under their API Name.
34
+
35
+ ## What those builds learned the hard way
36
+
37
+ - **Claim the "already done" flag before the first `await`.** Checking and then
38
+ awaiting lets every concurrent load past the check — three bundle loads
39
+ produced three `identify` calls.
40
+ - **Keep the flag on `window`, not in module scope.** A bundle evaluated twice
41
+ gets two module scopes and identifies twice.
42
+ - **An account endpoint may describe the account, not the person.** One returned
43
+ the billing contact's name and email for whoever was signed in — which labels
44
+ every colleague as the same person. Read the person from the session; read
45
+ only the account id from the account.
46
+ - **No id means stay anonymous.** A wrong id silently merges or splits real
47
+ people in the analytics. Never invent one.
48
+ - **A failed identify must not break the agent.** Every failure is silent to the
49
+ user, and releases the flag so a later load can try again.
50
+ - **Hide the agent until identify resolves, with a timeout** — and on a page
51
+ with no signed-in user (the login page), do not show it at all.
52
+ - **This is unsigned.** Anyone can call `foldspace.identify` from a console.
53
+ Signing has to come from the customer's backend
54
+ (<https://docs.foldspace.ai/security/overview/>); until then treat the
55
+ analytics as indicative. And if the customer's own page ever calls
56
+ `identify`, delete this file — theirs runs earlier and knows more.
@@ -0,0 +1,89 @@
1
+ // Tell Foldspace who the signed-in user is — once, when the SDK is ready.
2
+ // Context shape: https://docs.foldspace.ai/start/user-context/
3
+ //
4
+ // UNSIGNED: anyone can call foldspace.identify from a console. Signing has to
5
+ // come from the customer's backend. See this recipe's README.
6
+
7
+ import { apiFetch, getAuthToken, parseJwt, redact } from "./utils";
8
+
9
+ /** Claims you OBSERVED in the app's session token. Replace these names. */
10
+ type SessionClaims = {
11
+ sub?: string | number;
12
+ email?: string;
13
+ };
14
+
15
+ /**
16
+ * The endpoint that describes THE SIGNED-IN USER. Check it against a second
17
+ * account member before trusting it: one app's account endpoint returned the
18
+ * billing contact for everyone.
19
+ */
20
+ type Profile = {
21
+ firstName?: string | null;
22
+ lastName?: string | null;
23
+ role?: string | null;
24
+ organizationId?: string | number | null;
25
+ };
26
+
27
+ // On window, not in module scope: a bundle evaluated twice on one page gets two
28
+ // module scopes, and would identify twice.
29
+ const IDENTIFIED_FLAG = "__foldspace_identified__";
30
+
31
+ const claimed = (): boolean => (window as any)[IDENTIFIED_FLAG] === true;
32
+ const claim = (): void => void ((window as any)[IDENTIFIED_FLAG] = true);
33
+ const release = (): void => void ((window as any)[IDENTIFIED_FLAG] = false);
34
+
35
+ function sessionIdentity(): { id: string; email: string | null } | null {
36
+ const token = getAuthToken();
37
+ const claims = token ? parseJwt<SessionClaims>(token) : null;
38
+ const raw = claims?.sub;
39
+ const id = typeof raw === "number" ? String(raw) : typeof raw === "string" ? raw.trim() : "";
40
+ if (!id) return null;
41
+ const email = typeof claims?.email === "string" && claims.email.includes("@") ? claims.email.trim() : null;
42
+ return { id, email };
43
+ }
44
+
45
+ /** Every failure is silent to the user: an unidentified session is a degraded record, never a broken agent. */
46
+ export function identifyUser(): void {
47
+ const foldspace = (window as any).foldspace;
48
+ if (typeof foldspace !== "function" || typeof foldspace.identify !== "function") return;
49
+ if (claimed()) return;
50
+
51
+ foldspace("when", "ready", async () => {
52
+ if (claimed()) return;
53
+ // Claimed BEFORE the first await, or every concurrent load gets past the check.
54
+ claim();
55
+
56
+ try {
57
+ // No id means anonymous. Never invent one: a wrong id merges or splits
58
+ // real people in the analytics.
59
+ const who = sessionIdentity();
60
+ if (!who) {
61
+ release();
62
+ return;
63
+ }
64
+
65
+ const user: Record<string, unknown> = { id: who.id };
66
+ if (who.email) user.email = who.email;
67
+ const context: Record<string, unknown> = { user };
68
+
69
+ const profile = await apiFetch<Profile>("/__observe_me/profile");
70
+ if (profile.ok) {
71
+ const name = [profile.data?.firstName, profile.data?.lastName].filter(Boolean).join(" ");
72
+ if (name) user.name = name;
73
+ if (profile.data?.role) user.role = profile.data.role;
74
+ // One subscription, many users: whatever groups colleagues together.
75
+ const org = profile.data?.organizationId;
76
+ if (org !== null && org !== undefined && String(org) !== "") {
77
+ context.subscription = { id: String(org) };
78
+ }
79
+ }
80
+
81
+ foldspace.identify(context);
82
+ // Never log the identity itself — only that it fired.
83
+ console.debug("[foldspace] identified", redact(who.id), user.name ? "with name" : "id only");
84
+ } catch (error) {
85
+ release();
86
+ console.warn("[foldspace] identify skipped:", error);
87
+ }
88
+ });
89
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "firstName": "Dana",
3
+ "lastName": "Reyes",
4
+ "role": "admin",
5
+ "organizationId": 4410
6
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "title": "Who is the user",
3
+ "level": "L0",
4
+ "family": "user-context",
5
+ "kind": "startup",
6
+ "entry": "agent/identify.ts",
7
+ "outcome": "Foldspace knows who is signed in, once, when the SDK is ready",
8
+ "provenBy": 4
9
+ }
package/src/cli-help.mjs CHANGED
@@ -46,6 +46,7 @@ export function renderGeneralHelp(registry) {
46
46
  " foldspace help --json Print the machine-readable CLI contract",
47
47
  " foldspace help <command> --json Print one command contract",
48
48
  " foldspace --version Show package and protocol versions",
49
+ " foldspace upgrade --check Compare the installed pin to npm latest",
49
50
  "",
50
51
  "attach loads local actions and observes the normal agent experience.",
51
52
  "deploy is a separate remote publication step.",
@@ -21,7 +21,7 @@ export const CLI_COMMANDS = Object.freeze([
21
21
  group: "start",
22
22
  summary: "Create a configured Foldspace actions project",
23
23
  usage:
24
- "foldspace init [<directory>] [--product-id <id>] [--agent-key <key>] [--domain <host>] [--name <display-name>]",
24
+ "foldspace init [<directory>] [--product-id <id>] [--agent-key <key>] [--app-domain <host>] [--name <display-name>]",
25
25
  risk: "local-write",
26
26
  environment: "node",
27
27
  environmentVariables: [],
@@ -41,8 +41,9 @@ export const CLI_COMMANDS = Object.freeze([
41
41
  required: "non-interactive",
42
42
  deprecatedAliases: ["--agent-api-name"],
43
43
  }),
44
- value("--domain", "host", "Target hostname or HTTP(S) URL", {
44
+ value("--app-domain", "host", "Live app hostname or HTTP(S) URL", {
45
45
  required: "non-interactive",
46
+ deprecatedAliases: ["--domain"],
46
47
  }),
47
48
  value("--name", "display-name", "Display name", {
48
49
  default: "directory name",
@@ -51,7 +52,7 @@ export const CLI_COMMANDS = Object.freeze([
51
52
  prerequisites: [
52
53
  "Node 20 or newer",
53
54
  "A target directory that does not already exist",
54
- "Non-interactive use requires directory, product ID, Agent Key, and domain",
55
+ "Non-interactive use requires directory, product ID, Agent Key, and app domain",
55
56
  ],
56
57
  effects: ["Creates a new local project; never initializes Git"],
57
58
  next: [
@@ -61,6 +62,48 @@ export const CLI_COMMANDS = Object.freeze([
61
62
  "Run foldspace attach",
62
63
  ],
63
64
  }),
65
+ Object.freeze({
66
+ name: "upgrade",
67
+ entry: null,
68
+ group: "start",
69
+ summary: "Check for a newer harness and update the exact pin after asking",
70
+ usage:
71
+ "foldspace upgrade [--check | --yes] [--refresh-instructions]",
72
+ risk: "local-write",
73
+ environment: "node",
74
+ environmentVariables: [
75
+ "FOLDSPACE_PROJECT_DIR",
76
+ "FOLDSPACE_SKIP_UPDATE_CHECK",
77
+ ],
78
+ capabilities: ["project.upgrade"],
79
+ positionals: [],
80
+ options: [
81
+ flag("--check", "Compare versions and print JSON; never install"),
82
+ flag(
83
+ "--yes",
84
+ "Install the latest exact pin after the user has already agreed",
85
+ ),
86
+ flag(
87
+ "--refresh-instructions",
88
+ "Replace CLAUDE.md with an import of the package instructions",
89
+ ),
90
+ ],
91
+ prerequisites: [
92
+ "A consumer project with package.json",
93
+ "An exact @foldspace_npm/harness dependency unless only refreshing instructions",
94
+ ],
95
+ effects: [
96
+ "Without --yes, writes nothing except an optional update cache",
97
+ "With --yes, rewrites the exact harness pin and runs npm install --ignore-scripts",
98
+ "Does not rebuild dist/index.js or deploy",
99
+ "--refresh-instructions copies CLAUDE.md to CLAUDE.md.bak then writes the stub",
100
+ ],
101
+ next: [
102
+ "If outdated, ask the user, then run foldspace upgrade --yes",
103
+ "Run npm run build after a pin bump",
104
+ "Deploy only if product users should receive the rebuilt runtime",
105
+ ],
106
+ }),
64
107
  Object.freeze({
65
108
  name: "build",
66
109
  entry: "build-cli.mjs",
@@ -272,6 +315,7 @@ export const CLI_COMMANDS = Object.freeze([
272
315
 
273
316
  export const CAPABILITY_CATALOGUE = Object.freeze([
274
317
  ["project.scaffold", "Create a new configured project on local disk"],
318
+ ["project.upgrade", "Update the consuming project's exact harness pin"],
275
319
  ["artifact.build", "Build the portable dist/index.js action artifact"],
276
320
  ["browser.launch", "Launch an isolated local Chrome profile"],
277
321
  ["browser.cdp", "Connect to local Chrome through CDP"],
@@ -342,6 +386,7 @@ export function createCliRegistry({ packageName, packageVersion }) {
342
386
  "The harness bin alias is equivalent to foldspace.",
343
387
  "attach diagnostics are attach-internal; interpret them from the lifecycle log, not as CLI commands.",
344
388
  "Coding agents should run attach --daemon; foreground attach is for humans watching the terminal.",
389
+ "If update.outdated is true, ask the user before foldspace upgrade --yes.",
345
390
  ],
346
391
  };
347
392
  }
package/src/init.mjs CHANGED
@@ -13,6 +13,7 @@ const allowedFlags = new Set([
13
13
  "product-id",
14
14
  "agent-key",
15
15
  "agent-api-name",
16
+ "app-domain",
16
17
  "domain",
17
18
  ]);
18
19
  const tokenPattern = /\{\{([A-Z0-9_]+)\}\}/g;
@@ -86,13 +87,18 @@ export function parseInitArgs(argv) {
86
87
  "Use --agent-key only; --agent-api-name is its deprecated alias.",
87
88
  );
88
89
  }
90
+ if (flags["app-domain"] && flags.domain) {
91
+ throw new Error(
92
+ "Use --app-domain only; --domain is its deprecated alias.",
93
+ );
94
+ }
89
95
 
90
96
  return {
91
97
  directory: positional[0] || null,
92
98
  displayName: flags.name || null,
93
99
  productId: flags["product-id"] || null,
94
100
  agentApiName: flags["agent-key"] || flags["agent-api-name"] || null,
95
- domain: flags.domain || null,
101
+ domain: flags["app-domain"] || flags.domain || null,
96
102
  };
97
103
  }
98
104
 
@@ -101,7 +107,7 @@ function missingInitFields(parsed) {
101
107
  if (!parsed.directory) missing.push("directory");
102
108
  if (!parsed.productId) missing.push("product-id");
103
109
  if (!parsed.agentApiName) missing.push("agent-key");
104
- if (!parsed.domain) missing.push("domain");
110
+ if (!parsed.domain) missing.push("app-domain");
105
111
  return missing;
106
112
  }
107
113
 
@@ -134,14 +140,14 @@ function validateProductId(value) {
134
140
  function normalizeTarget(value) {
135
141
  const raw = value.trim();
136
142
  if (!raw || raw.includes("*")) {
137
- throw new Error("Domain must be a concrete hostname without a wildcard.");
143
+ throw new Error("App domain must be a concrete hostname without a wildcard.");
138
144
  }
139
145
 
140
146
  let url;
141
147
  try {
142
148
  url = new URL(raw.includes("://") ? raw : `https://${raw}`);
143
149
  } catch {
144
- throw new Error(`Invalid domain: ${value}`);
150
+ throw new Error(`Invalid app domain: ${value}`);
145
151
  }
146
152
 
147
153
  if (
@@ -153,12 +159,12 @@ function normalizeTarget(value) {
153
159
  url.search ||
154
160
  url.hash
155
161
  ) {
156
- throw new Error("Domain must contain only an HTTP(S) hostname, without credentials, a port, path, query, or fragment.");
162
+ throw new Error("App domain must contain only an HTTP(S) hostname, without credentials, a port, path, query, or fragment.");
157
163
  }
158
164
 
159
165
  const domain = url.hostname.toLowerCase().replace(/\.$/, "");
160
166
  if (!domain || domain.includes("..")) {
161
- throw new Error(`Invalid domain: ${value}`);
167
+ throw new Error(`Invalid app domain: ${value}`);
162
168
  }
163
169
 
164
170
  return {
@@ -227,7 +233,7 @@ async function promptForMissingFields(parsed, options = {}) {
227
233
 
228
234
  if (!next.domain) {
229
235
  next.domain = await askUntilValid(
230
- "Domain",
236
+ "App domain",
231
237
  (value) => {
232
238
  normalizeTarget(value);
233
239
  return value.trim();
@@ -255,7 +261,7 @@ function finalizeInitConfig(parsed) {
255
261
  for (const [key, label] of [
256
262
  ["productId", "product-id"],
257
263
  ["agentApiName", "agent-key"],
258
- ["domain", "domain"],
264
+ ["domain", "app-domain"],
259
265
  ]) {
260
266
  if (!parsed[key]) {
261
267
  throw new Error(`Missing required option: --${label}\nUsage: ${initUsage}`);
@@ -304,7 +310,9 @@ function createTemplateValues(config, harnessVersion) {
304
310
  DISPLAY_NAME: displayName,
305
311
  PACKAGE_NAME: packageName,
306
312
  PACKAGE_DESCRIPTION_JSON: JSON.stringify(`Foldspace browser actions for ${displayName}.`),
313
+ PRODUCT_ID: productId,
307
314
  PRODUCT_ID_JSON: JSON.stringify(productId),
315
+ AGENT_API_NAME: agentApiName,
308
316
  AGENT_API_NAME_JSON: JSON.stringify(agentApiName),
309
317
  APP_DOMAIN: target.domain,
310
318
  APP_DOMAIN_JSON: JSON.stringify(target.domain),
@@ -26,7 +26,7 @@ export interface AuthSource {
26
26
  * wrong place.
27
27
  */
28
28
  export interface RuntimeConfig {
29
- /** Foldspace agent apiName, e.g. `joist-agent`. */
29
+ /** Foldspace agent apiName, e.g. `acme-agent`. */
30
30
  agentApiName: string;
31
31
  /**
32
32
  * Prefix for `apiFetch` / `apiFetchBinary`. Include an explicit port if the