@foldspace_npm/harness 0.1.7 → 0.1.9

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 CHANGED
@@ -27,6 +27,7 @@ constraint this package exists to protect.
27
27
 
28
28
  - `agent/actions/*` and `agent/api/*` — a handler is `fetch` plus `runTask`,
29
29
  nothing more. The actions built for Figma run unmodified in either track.
30
+ - `agent/utils.ts` — configures and re-exports `@foldspace_npm/harness/runtime`
30
31
  - `foldspace build` — esbuild → `dist/index.js`
31
32
  - `foldspace deploy` — publish to `agent/actions/<env>/<productId>/<agentApiName>`
32
33
  - fixtures and tests
@@ -93,7 +94,7 @@ npx --yes @foldspace_npm/harness init
93
94
  Non-interactive / CI form:
94
95
 
95
96
  ```bash
96
- npx --yes @foldspace_npm/harness init my-agent \
97
+ npx --yes @foldspace_npm/harness init foldspace-agent \
97
98
  --product-id FR8JUQZAQRZB \
98
99
  --agent-key my-agent \
99
100
  --domain app.example.com \
@@ -103,24 +104,25 @@ npx --yes @foldspace_npm/harness init my-agent \
103
104
  When running directly from a harness checkout during development:
104
105
 
105
106
  ```bash
106
- node bin/cli.mjs init ../my-agent \
107
+ node bin/cli.mjs init ../foldspace-agent \
107
108
  --product-id FR8JUQZAQRZB \
108
109
  --agent-key my-agent \
109
110
  --domain app.example.com
110
111
  ```
111
112
 
112
- The Agent Key is the value shown in Agent Studio, such as `my-agent`.
113
- `--agent-api-name` remains a deprecated alias for `--agent-key`. `--name` is
114
- optional and defaults to the target directory name. The product ID must be the
115
- bare ID, not the `EU-…-1-1` SDK loader key. The domain may be a hostname or an
116
- HTTP(S) URL without a port or path.
113
+ The Agent Key is the value shown in Agent Studio, such as `my-agent`. It is
114
+ not the sidecar directory name. `--agent-api-name` remains a deprecated alias
115
+ for `--agent-key`. `--name` is optional and defaults to the target directory
116
+ name (`foldspace-agent` unless you pass a directory). The product ID must be
117
+ the bare ID, not the `EU-…-1-1` SDK loader key. The domain may be a hostname
118
+ or an HTTP(S) URL without a port or path.
117
119
 
118
120
  For safety, `init` requires a target path that does not exist. It does not
119
121
  initialize Git or overwrite files. On a TTY it can offer
120
122
  `npm install --ignore-scripts` after scaffolding. After creation:
121
123
 
122
124
  ```bash
123
- cd my-agent
125
+ cd foldspace-agent
124
126
  npm install --ignore-scripts # if you skipped the install prompt
125
127
  npm run build
126
128
  npm run inject
@@ -130,6 +132,34 @@ npm run attach
130
132
  The generated npm scripts intentionally remain the normal project interface;
131
133
  `foldspace init` is the one-time project creation command.
132
134
 
135
+ ### Lint handlers before they ship
136
+
137
+ `foldspace build` runs `foldspace lint` first. Errors skip bundling; warnings
138
+ print and still produce `dist/index.js`.
139
+
140
+ ```bash
141
+ foldspace lint
142
+ foldspace lint --json
143
+ ```
144
+
145
+ v1 flags:
146
+
147
+ - **error** `no-static-agent-prompt` — `execute` / chatterblock `callback` returning copilot instructions (`directive`, “tell the user”, …)
148
+ - **error** `no-runtask-prompt` — `runTask({ data: { prompt } })` and the same keys; a customer API field named `prompt` is allowed
149
+ - **warning** `no-emoji` — emoji in handler or `agent/views` string literals
150
+ - **warning** `registry-integrity` — duplicate keys, `{ execute }` not registered, registry value missing `execute`
151
+
152
+ Put action instructions in Agent Studio or MCP. `foldspace lint` cannot see that copy.
153
+
154
+ ### Runtime helpers
155
+
156
+ Tenant actions import from `agent/utils.ts`, which configures and re-exports
157
+ `@foldspace_npm/harness/runtime`. That code is bundled into `dist/index.js` and
158
+ runs in the customer's page — it is not a CLI command.
159
+
160
+ `API_BASE` and `AUTH_SOURCE` stamp empty. Fill them from a captured XHR, not a
161
+ guess. Custom auth headers stay a tenant override of one function in `utils.ts`.
162
+
133
163
  ### Choose an attach mode
134
164
 
135
165
  - **Swap (default):** the page already uses the configured product and agent.
package/bin/build.ts CHANGED
@@ -2,6 +2,7 @@ import * as esbuild from "esbuild";
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
4
  import { fileURLToPath } from "url";
5
+ import { lintProject, printLint } from "../src/lint/index.mjs";
5
6
 
6
7
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
8
  const projectDir = process.env.FOLDSPACE_PROJECT_DIR || process.cwd();
@@ -10,6 +11,14 @@ const agentActionsEntry = path.join(projectDir, "agent", "actions", "index.ts");
10
11
 
11
12
  const watchMode = process.argv.includes("--watch");
12
13
 
14
+ function gateLint(): void {
15
+ const result = lintProject(projectDir);
16
+ printLint(result);
17
+ if (result.errorCount > 0) {
18
+ process.exit(1);
19
+ }
20
+ }
21
+
13
22
  function buildOptions(): esbuild.BuildOptions {
14
23
  return {
15
24
  entryPoints: [agentActionsEntry],
@@ -27,6 +36,13 @@ function rebuildPlugin(): esbuild.Plugin {
27
36
  return {
28
37
  name: "rebuild-notify",
29
38
  setup(build) {
39
+ build.onStart(() => {
40
+ const result = lintProject(projectDir);
41
+ printLint(result);
42
+ if (result.errorCount > 0) {
43
+ throw new Error("foldspace lint failed");
44
+ }
45
+ });
30
46
  build.onEnd((result) => {
31
47
  const time = new Date().toLocaleTimeString();
32
48
  if (result.errors.length === 0) {
@@ -47,6 +63,10 @@ async function build() {
47
63
  process.exit(1);
48
64
  }
49
65
 
66
+ if (!watchMode) {
67
+ gateLint();
68
+ }
69
+
50
70
  if (!fs.existsSync(distDir)) {
51
71
  fs.mkdirSync(distDir, { recursive: true });
52
72
  }
package/bin/lint.mjs ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { lintProject, printLint } from "../src/lint/index.mjs";
4
+
5
+ const projectDir = process.env.FOLDSPACE_PROJECT_DIR || process.cwd();
6
+ const json = process.argv.includes("--json");
7
+ const result = lintProject(projectDir);
8
+ printLint(result, { json });
9
+ process.exit(result.errorCount > 0 ? 1 : 0);
package/package.json CHANGED
@@ -1,12 +1,19 @@
1
1
  {
2
2
  "name": "@foldspace_npm/harness",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Build and verify portable Foldspace action artifacts against a live app.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "foldspace": "bin/cli.mjs",
8
8
  "harness": "bin/cli.mjs"
9
9
  },
10
+ "exports": {
11
+ "./runtime": {
12
+ "types": "./src/runtime/index.ts",
13
+ "default": "./src/runtime/index.ts"
14
+ },
15
+ "./package.json": "./package.json"
16
+ },
10
17
  "scripts": {
11
18
  "test": "node --test test/*.test.mjs"
12
19
  },
@@ -18,7 +25,8 @@
18
25
  ],
19
26
  "dependencies": {
20
27
  "esbuild": "^0.20.0",
21
- "tsx": "^4.7.0"
28
+ "tsx": "^4.7.0",
29
+ "typescript": "^5.3.3"
22
30
  },
23
31
  "engines": {
24
32
  "node": ">=20"
@@ -74,9 +74,33 @@ export const CLI_COMMANDS = Object.freeze([
74
74
  positionals: [],
75
75
  options: [flag("--watch", "Rebuild when action source changes")],
76
76
  prerequisites: ["agent/actions/index.ts"],
77
- effects: ["Writes dist/index.js"],
77
+ effects: [
78
+ "Runs foldspace lint first; errors skip bundling",
79
+ "Writes dist/index.js when lint has no errors",
80
+ ],
78
81
  next: ["Run foldspace inject", "Run foldspace attach"],
79
82
  }),
83
+ Object.freeze({
84
+ name: "lint",
85
+ entry: "lint.mjs",
86
+ group: "develop",
87
+ summary: "Check action handlers for prompt, registry, and copy issues",
88
+ usage: "foldspace lint [--json]",
89
+ risk: "local-write",
90
+ environment: "node",
91
+ environmentVariables: ["FOLDSPACE_PROJECT_DIR"],
92
+ capabilities: ["artifact.build"],
93
+ positionals: [],
94
+ options: [
95
+ flag("--json", "Print findings as machine-readable JSON"),
96
+ ],
97
+ prerequisites: ["agent/actions/index.ts"],
98
+ effects: [
99
+ "Reads agent/actions (and agent/views when present); writes nothing",
100
+ "Errors fail the process; warnings print and still exit 0",
101
+ ],
102
+ next: ["Fix reported findings", "Run foldspace build"],
103
+ }),
80
104
  Object.freeze({
81
105
  name: "inject",
82
106
  entry: "inject.mjs",
package/src/init.mjs CHANGED
@@ -16,7 +16,7 @@ const allowedFlags = new Set([
16
16
  "domain",
17
17
  ]);
18
18
  const tokenPattern = /\{\{([A-Z0-9_]+)\}\}/g;
19
- const defaultDirectory = "my-agent";
19
+ const defaultDirectory = "foldspace-agent";
20
20
 
21
21
  export const initUsage = commandByName("init").usage;
22
22
 
@@ -306,7 +306,9 @@ function createTemplateValues(config, harnessVersion) {
306
306
  PACKAGE_DESCRIPTION_JSON: JSON.stringify(`Foldspace browser actions for ${displayName}.`),
307
307
  PRODUCT_ID_JSON: JSON.stringify(productId),
308
308
  AGENT_API_NAME_JSON: JSON.stringify(agentApiName),
309
+ APP_DOMAIN: target.domain,
309
310
  APP_DOMAIN_JSON: JSON.stringify(target.domain),
311
+ START_URL: target.startUrl,
310
312
  START_URL_JSON: JSON.stringify(target.startUrl),
311
313
  HOSTS_JSON: JSON.stringify(hosts),
312
314
  HARNESS_VERSION: harnessVersion,
@@ -0,0 +1,237 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { ts } from "./ts.mjs";
4
+
5
+ export function listSourceFiles(dir) {
6
+ if (!fs.existsSync(dir)) return [];
7
+ const out = [];
8
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
9
+ const full = path.join(dir, entry.name);
10
+ if (entry.isDirectory()) {
11
+ out.push(...listSourceFiles(full));
12
+ continue;
13
+ }
14
+ if (entry.isFile() && /\.(ts|js)$/.test(entry.name) && !entry.name.endsWith(".d.ts")) {
15
+ out.push(full);
16
+ }
17
+ }
18
+ return out.sort();
19
+ }
20
+
21
+ export function shouldSkipActionFile(filePath) {
22
+ const base = path.basename(filePath);
23
+ return base.startsWith("_example") || /\.test\./.test(base);
24
+ }
25
+
26
+ export function parseFile(filePath) {
27
+ const text = fs.readFileSync(filePath, "utf8");
28
+ const kind = filePath.endsWith(".ts") ? ts.ScriptKind.TS : ts.ScriptKind.JS;
29
+ return ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, kind);
30
+ }
31
+
32
+ export function loc(sourceFile, node) {
33
+ const start = node.getStart(sourceFile);
34
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(start);
35
+ return { line: line + 1, column: character + 1 };
36
+ }
37
+
38
+ export function propertyName(node) {
39
+ if (!node?.name) return null;
40
+ if (ts.isIdentifier(node.name) || ts.isPrivateIdentifier(node.name)) {
41
+ return node.name.text;
42
+ }
43
+ if (ts.isStringLiteral(node.name) || ts.isNumericLiteral(node.name)) {
44
+ return node.name.text;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ export function normalizeKey(name) {
50
+ return String(name || "")
51
+ .replace(/_/g, "")
52
+ .toLowerCase();
53
+ }
54
+
55
+ export function isFunctionLike(node) {
56
+ return (
57
+ node &&
58
+ (ts.isFunctionDeclaration(node) ||
59
+ ts.isFunctionExpression(node) ||
60
+ ts.isArrowFunction(node) ||
61
+ ts.isMethodDeclaration(node) ||
62
+ ts.isConstructorDeclaration(node))
63
+ );
64
+ }
65
+
66
+ function initializerOf(node) {
67
+ if (ts.isPropertyAssignment(node)) return node.initializer;
68
+ if (ts.isMethodDeclaration(node)) return node;
69
+ if (ts.isPropertyDeclaration(node)) return node.initializer;
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * Call `visit(fn, name)` for execute / render / callback function-likes.
75
+ */
76
+ export function forEachNamedFunction(sourceFile, names, visit) {
77
+ const wanted = names instanceof Set ? names : new Set(names);
78
+
79
+ function walk(node) {
80
+ if (
81
+ ts.isPropertyAssignment(node) ||
82
+ ts.isMethodDeclaration(node) ||
83
+ ts.isPropertyDeclaration(node)
84
+ ) {
85
+ const name = propertyName(node);
86
+ if (name && wanted.has(name)) {
87
+ const target = initializerOf(node);
88
+ if (isFunctionLike(target)) visit(target, name, node);
89
+ }
90
+ }
91
+ ts.forEachChild(node, walk);
92
+ }
93
+
94
+ walk(sourceFile);
95
+ }
96
+
97
+ export function callbackParamName(fn) {
98
+ const params = fn.parameters || [];
99
+ if (params.length >= 4 && ts.isIdentifier(params[3].name)) {
100
+ return params[3].name.text;
101
+ }
102
+ return "callback";
103
+ }
104
+
105
+ export function collectConstBindings(scopeNode) {
106
+ const map = new Map();
107
+
108
+ function visit(node) {
109
+ if (node !== scopeNode && isFunctionLike(node)) return;
110
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
111
+ map.set(node.name.text, node.initializer);
112
+ }
113
+ ts.forEachChild(node, visit);
114
+ }
115
+
116
+ visit(scopeNode);
117
+ return map;
118
+ }
119
+
120
+ export function collectModuleBindings(sourceFile) {
121
+ const map = new Map();
122
+ for (const stmt of sourceFile.statements) {
123
+ if (!ts.isVariableStatement(stmt)) continue;
124
+ for (const decl of stmt.declarationList.declarations) {
125
+ if (ts.isIdentifier(decl.name) && decl.initializer) {
126
+ map.set(decl.name.text, decl.initializer);
127
+ }
128
+ }
129
+ }
130
+ return map;
131
+ }
132
+
133
+ export function resolveNode(node, bindings, depth = 0) {
134
+ if (!node || depth > 8) return node;
135
+ if (ts.isIdentifier(node)) {
136
+ const next = bindings.get(node.text);
137
+ if (!next) return node;
138
+ return resolveNode(next, bindings, depth + 1);
139
+ }
140
+ if (ts.isAsExpression(node) || ts.isParenthesizedExpression(node) || ts.isSatisfiesExpression(node)) {
141
+ return resolveNode(node.expression, bindings, depth + 1);
142
+ }
143
+ return node;
144
+ }
145
+
146
+ export function stringText(node) {
147
+ if (!node) return null;
148
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
149
+ return node.text;
150
+ }
151
+ if (ts.isTemplateExpression(node)) {
152
+ return [
153
+ node.head.text,
154
+ ...node.templateSpans.map((span) => span.literal.text),
155
+ ].join("");
156
+ }
157
+ return null;
158
+ }
159
+
160
+ export function forEachStringLiteral(node, visit, { skipNestedFunctions = false, root = node } = {}) {
161
+ function walk(current) {
162
+ if (skipNestedFunctions && current !== root && isFunctionLike(current)) return;
163
+ if (
164
+ ts.isStringLiteral(current) ||
165
+ ts.isNoSubstitutionTemplateLiteral(current) ||
166
+ ts.isTemplateExpression(current)
167
+ ) {
168
+ visit(current);
169
+ }
170
+ ts.forEachChild(current, walk);
171
+ }
172
+ walk(node);
173
+ }
174
+
175
+ export function forEachReturn(fn, visit) {
176
+ if (ts.isArrowFunction(fn) && fn.body && !ts.isBlock(fn.body)) {
177
+ visit(fn.body, fn);
178
+ return;
179
+ }
180
+ function walk(node) {
181
+ if (node !== fn && isFunctionLike(node)) return;
182
+ if (ts.isReturnStatement(node) && node.expression) visit(node.expression, node);
183
+ ts.forEachChild(node, walk);
184
+ }
185
+ walk(fn);
186
+ }
187
+
188
+ export function forEachCallNamed(fn, name, visit) {
189
+ function walk(node) {
190
+ if (node !== fn && isFunctionLike(node)) return;
191
+ if (ts.isCallExpression(node)) {
192
+ if (ts.isIdentifier(node.expression) && node.expression.text === name) {
193
+ visit(node);
194
+ }
195
+ }
196
+ ts.forEachChild(node, walk);
197
+ }
198
+ walk(fn);
199
+ }
200
+
201
+ export function objectProperties(obj) {
202
+ if (!obj || !ts.isObjectLiteralExpression(obj)) return [];
203
+ return obj.properties.filter(
204
+ (prop) => ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop),
205
+ );
206
+ }
207
+
208
+ export function propertyValue(prop) {
209
+ if (ts.isShorthandPropertyAssignment(prop)) return prop.name;
210
+ return prop.initializer;
211
+ }
212
+
213
+ export function resolveModulePath(fromFile, specifier) {
214
+ if (!specifier || !specifier.startsWith(".")) return null;
215
+ const base = path.resolve(path.dirname(fromFile), specifier);
216
+ const candidates = [
217
+ base,
218
+ `${base}.ts`,
219
+ `${base}.js`,
220
+ path.join(base, "index.ts"),
221
+ path.join(base, "index.js"),
222
+ ];
223
+ return candidates.find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile()) || null;
224
+ }
225
+
226
+ export function finding({ rule, severity, sourceFile, node, message, fix, file }) {
227
+ const position = loc(sourceFile, node);
228
+ return {
229
+ rule,
230
+ severity,
231
+ file,
232
+ line: position.line,
233
+ column: position.column,
234
+ message,
235
+ fix,
236
+ };
237
+ }
@@ -0,0 +1,101 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { listSourceFiles, parseFile, shouldSkipActionFile } from "./ast.mjs";
4
+ import { lintActionFile, lintViewsFile, ruleRegistryIntegrity } from "./rules.mjs";
5
+
6
+ export const LINT_SCHEMA_VERSION = 1;
7
+
8
+ export function lintProject(projectDir) {
9
+ const actionsDir = path.join(projectDir, "agent", "actions");
10
+ const indexFile = path.join(actionsDir, "index.ts");
11
+ const indexJs = path.join(actionsDir, "index.js");
12
+ const entry = fs.existsSync(indexFile) ? indexFile : fs.existsSync(indexJs) ? indexJs : null;
13
+
14
+ if (!entry) {
15
+ return {
16
+ ok: false,
17
+ errorCount: 1,
18
+ warningCount: 0,
19
+ findings: [
20
+ {
21
+ rule: "registry-integrity",
22
+ severity: "error",
23
+ file: "agent/actions/index.ts",
24
+ line: 1,
25
+ column: 1,
26
+ message: "Entry point not found: agent/actions/index.ts",
27
+ fix: "Create agent/actions/index.ts and register handlers there.",
28
+ },
29
+ ],
30
+ };
31
+ }
32
+
33
+ const findings = [];
34
+ const actionFiles = listSourceFiles(actionsDir);
35
+
36
+ for (const filePath of actionFiles) {
37
+ lintActionFile(filePath, path.relative(projectDir, filePath), findings);
38
+ }
39
+
40
+ const viewsDir = path.join(projectDir, "agent", "views");
41
+ if (fs.existsSync(viewsDir)) {
42
+ for (const filePath of listSourceFiles(viewsDir)) {
43
+ lintViewsFile(filePath, path.relative(projectDir, filePath), findings);
44
+ }
45
+ }
46
+
47
+ ruleRegistryIntegrity({
48
+ indexFile: entry,
49
+ indexSource: parseFile(entry),
50
+ actionFiles,
51
+ projectDir,
52
+ findings,
53
+ });
54
+
55
+ const errorCount = findings.filter((item) => item.severity === "error").length;
56
+ const warningCount = findings.filter((item) => item.severity === "warning").length;
57
+ return {
58
+ ok: errorCount === 0,
59
+ errorCount,
60
+ warningCount,
61
+ findings,
62
+ };
63
+ }
64
+
65
+ function formatFinding(item) {
66
+ return `${item.file}:${item.line}:${item.column} ${item.severity} ${item.rule} ${item.message}\n Fix: ${item.fix}`;
67
+ }
68
+
69
+ export function renderLintText(result) {
70
+ if (!result.findings.length) {
71
+ return "foldspace lint: no issues";
72
+ }
73
+ const body = result.findings.map(formatFinding).join("\n");
74
+ const summary = `${result.errorCount} error${result.errorCount === 1 ? "" : "s"}, ${result.warningCount} warning${result.warningCount === 1 ? "" : "s"}`;
75
+ return `${body}\n\n${summary}`;
76
+ }
77
+
78
+ export function renderLintJson(result) {
79
+ return {
80
+ kind: "foldspace.lint",
81
+ schemaVersion: LINT_SCHEMA_VERSION,
82
+ ok: result.ok,
83
+ errorCount: result.errorCount,
84
+ warningCount: result.warningCount,
85
+ findings: result.findings,
86
+ };
87
+ }
88
+
89
+ export function printLint(result, { json = false, stdout = console.log, stderr = console.error } = {}) {
90
+ if (json) {
91
+ stdout(JSON.stringify(renderLintJson(result), null, 2));
92
+ return;
93
+ }
94
+ const text = renderLintText(result);
95
+ if (result.errorCount > 0) stderr(text);
96
+ else stdout(text);
97
+ }
98
+
99
+ export function shouldSkipFile(filePath) {
100
+ return shouldSkipActionFile(filePath);
101
+ }