@firenet-designs/fnd-cli 2.4.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +194 -57
- package/bin/dev.js +1 -1
- package/dist/commands/alt-text.d.ts +105 -0
- package/dist/commands/alt-text.js +616 -0
- package/dist/commands/backfill-project.js +1 -1
- package/dist/commands/create-project.js +48 -5
- package/dist/commands/workspace/index.d.ts +19 -2
- package/dist/commands/workspace/index.js +171 -56
- package/dist/lib/alt-text.d.ts +87 -0
- package/dist/lib/alt-text.js +196 -0
- package/dist/lib/image-filter.d.ts +43 -0
- package/dist/lib/image-filter.js +71 -0
- package/dist/lib/mcp/bracket-args.d.ts +37 -0
- package/dist/lib/mcp/bracket-args.js +65 -0
- package/dist/lib/mcp/define-tool.d.ts +52 -0
- package/dist/lib/mcp/define-tool.js +2 -0
- package/dist/lib/mcp/registry.d.ts +38 -0
- package/dist/lib/mcp/registry.js +98 -0
- package/dist/lib/mcp/server.d.ts +66 -0
- package/dist/lib/mcp/server.js +176 -0
- package/dist/lib/mcp/tools/shopify-common.d.ts +139 -0
- package/dist/lib/mcp/tools/shopify-common.js +167 -0
- package/dist/lib/mcp/tools/shopify-execute.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-execute.js +105 -0
- package/dist/lib/mcp/tools/shopify-file-delete.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-file-delete.js +49 -0
- package/dist/lib/mcp/tools/shopify-file-replace.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-file-replace.js +79 -0
- package/dist/lib/mcp/tools/shopify-file-search.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-file-search.js +199 -0
- package/dist/lib/mcp/tools/shopify-file-upload.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-file-upload.js +76 -0
- package/dist/lib/shopify/graphql/AccessScopes.graphql +7 -0
- package/dist/lib/shopify/graphql/CurrentBulkOperation.graphql +8 -0
- package/dist/lib/shopify/graphql/FileCreate.graphql +25 -0
- package/dist/lib/shopify/graphql/FileDelete.graphql +11 -0
- package/dist/lib/shopify/graphql/FileReplace.graphql +26 -0
- package/dist/lib/shopify/graphql/FileStatus.graphql +19 -0
- package/dist/lib/shopify/graphql/FilesBulkQuery.graphql +27 -0
- package/dist/lib/shopify/graphql/ProductsBulkQuery.graphql +27 -0
- package/dist/lib/shopify/graphql/SearchFiles.graphql +36 -0
- package/dist/lib/shopify/graphql/StagedUploadsCreate.graphql +20 -0
- package/dist/lib/shopify/graphql/StartBulkQuery.graphql +16 -0
- package/dist/lib/shopify/graphql/UpdateFileAlt.graphql +9 -0
- package/dist/lib/shopify/shopify.d.ts +228 -0
- package/dist/lib/shopify/shopify.js +662 -0
- package/dist/lib/webflow.d.ts +80 -0
- package/dist/lib/webflow.js +122 -0
- package/dist/lib/workspace.d.ts +29 -10
- package/dist/lib/workspace.js +74 -39
- package/oclif.manifest.json +162 -78
- package/package.json +21 -10
- package/dist/commands/workspace/cleanup.d.ts +0 -14
- package/dist/commands/workspace/cleanup.js +0 -84
- package/dist/hooks/init/check-for-updates.d.ts +0 -3
- package/dist/hooks/init/check-for-updates.js +0 -15
- package/dist/lib/kv-flag.d.ts +0 -15
- package/dist/lib/kv-flag.js +0 -75
- package/dist/lib/rpc.d.ts +0 -69
- package/dist/lib/rpc.js +0 -313
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Webflow Data API v2 client — only what `fnd alt-text` needs.
|
|
3
|
+
*
|
|
4
|
+
* Two kinds of images live in a Webflow site and they are updated through
|
|
5
|
+
* completely different endpoints:
|
|
6
|
+
*
|
|
7
|
+
* site asset library /v2/sites/:site/assets -> PATCH /v2/assets/:id {altText}
|
|
8
|
+
* CMS image fields /v2/collections/:id/items -> PATCH /v2/collections/:id/items {items[].fieldData}
|
|
9
|
+
*
|
|
10
|
+
* Read endpoints are async generators that paginate internally, so callers just
|
|
11
|
+
* `for await` and never deal with offsets.
|
|
12
|
+
*
|
|
13
|
+
* SECURITY: the API key is a site-wide bearer token. It is only ever put in an
|
|
14
|
+
* Authorization header — never logged, never included in an error message (we
|
|
15
|
+
* report the URL pathname, not the full URL, in case a token ever ends up in a
|
|
16
|
+
* query string).
|
|
17
|
+
*/
|
|
18
|
+
export interface WebflowAuth {
|
|
19
|
+
apiKey: string;
|
|
20
|
+
siteId: string;
|
|
21
|
+
}
|
|
22
|
+
export interface Asset {
|
|
23
|
+
altText: null | string;
|
|
24
|
+
contentType: string;
|
|
25
|
+
displayName: string;
|
|
26
|
+
hostedUrl: string;
|
|
27
|
+
id: string;
|
|
28
|
+
originalFileName: string;
|
|
29
|
+
siteId: string;
|
|
30
|
+
}
|
|
31
|
+
export interface Collection {
|
|
32
|
+
displayName: string;
|
|
33
|
+
id: string;
|
|
34
|
+
singularName: string;
|
|
35
|
+
slug: string;
|
|
36
|
+
}
|
|
37
|
+
/** A single CMS image value. `alt` is null until someone (or this command) fills it in. */
|
|
38
|
+
export interface ImageField {
|
|
39
|
+
alt: null | string;
|
|
40
|
+
fileId: string;
|
|
41
|
+
url: string;
|
|
42
|
+
}
|
|
43
|
+
export interface CollectionItem {
|
|
44
|
+
fieldData: Record<string, ImageField | ImageField[] | unknown>;
|
|
45
|
+
id: string;
|
|
46
|
+
isArchived: boolean;
|
|
47
|
+
isDraft: boolean;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Every asset in the site's asset library, oldest page first.
|
|
51
|
+
*
|
|
52
|
+
* @yields each asset, one page of 100 at a time.
|
|
53
|
+
*/
|
|
54
|
+
export declare function getAssets(auth: WebflowAuth, limit?: number): AsyncGenerator<Asset>;
|
|
55
|
+
/** Every CMS collection on the site. Not paginated by Webflow. */
|
|
56
|
+
export declare const getCollections: (auth: WebflowAuth) => Promise<Collection[]>;
|
|
57
|
+
/**
|
|
58
|
+
* Every item in a collection.
|
|
59
|
+
*
|
|
60
|
+
* Reads from STAGING by default (the `/live` endpoint is opt-in) to match
|
|
61
|
+
* `updateCollectionItem`, which also writes to staging — so a run's changes
|
|
62
|
+
* need publishing in Webflow before they show on the live site.
|
|
63
|
+
*
|
|
64
|
+
* @yields each item in the collection.
|
|
65
|
+
*/
|
|
66
|
+
export declare function getCollectionItems(auth: WebflowAuth, collectionId: string, { limit, staging }?: {
|
|
67
|
+
limit?: number;
|
|
68
|
+
staging?: boolean;
|
|
69
|
+
}): AsyncGenerator<CollectionItem>;
|
|
70
|
+
/** Set the alt text on a site asset. */
|
|
71
|
+
export declare const updateAssetAltText: (auth: WebflowAuth, assetId: string, altText: string) => Promise<void>;
|
|
72
|
+
/** Patch one item's fieldData. Only the fields present in `fieldData` are touched. */
|
|
73
|
+
export declare const updateCollectionItem: (auth: WebflowAuth, collectionId: string, itemId: string, fieldData: Record<string, unknown>) => Promise<void>;
|
|
74
|
+
/**
|
|
75
|
+
* Webflow hands back untyped `fieldData`, so image fields are duck-typed: a
|
|
76
|
+
* single image is an object with a `url`, a multi-image field is an array of
|
|
77
|
+
* those (an empty array counts — it is still an image field, just empty).
|
|
78
|
+
*/
|
|
79
|
+
export declare const isImageField: (value: unknown) => value is ImageField;
|
|
80
|
+
export declare const isImagesField: (value: unknown) => value is ImageField[];
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal Webflow Data API v2 client — only what `fnd alt-text` needs.
|
|
3
|
+
*
|
|
4
|
+
* Two kinds of images live in a Webflow site and they are updated through
|
|
5
|
+
* completely different endpoints:
|
|
6
|
+
*
|
|
7
|
+
* site asset library /v2/sites/:site/assets -> PATCH /v2/assets/:id {altText}
|
|
8
|
+
* CMS image fields /v2/collections/:id/items -> PATCH /v2/collections/:id/items {items[].fieldData}
|
|
9
|
+
*
|
|
10
|
+
* Read endpoints are async generators that paginate internally, so callers just
|
|
11
|
+
* `for await` and never deal with offsets.
|
|
12
|
+
*
|
|
13
|
+
* SECURITY: the API key is a site-wide bearer token. It is only ever put in an
|
|
14
|
+
* Authorization header — never logged, never included in an error message (we
|
|
15
|
+
* report the URL pathname, not the full URL, in case a token ever ends up in a
|
|
16
|
+
* query string).
|
|
17
|
+
*/
|
|
18
|
+
const API = 'https://api.webflow.com/v2';
|
|
19
|
+
/** How many times a 429 is retried before the request is allowed to fail. */
|
|
20
|
+
const RATE_LIMIT_RETRIES = 3;
|
|
21
|
+
/** Fallback wait when Webflow rate-limits us without a Retry-After header. */
|
|
22
|
+
const RATE_LIMIT_FALLBACK_MS = 15_000;
|
|
23
|
+
const sleep = (ms) => new Promise((resolve) => {
|
|
24
|
+
setTimeout(resolve, ms);
|
|
25
|
+
});
|
|
26
|
+
/**
|
|
27
|
+
* One request against the Webflow API, with a bounded retry on 429.
|
|
28
|
+
*
|
|
29
|
+
* A full site run is hundreds of sequential requests spread over however long
|
|
30
|
+
* the vision model takes, so hitting the per-minute cap is a matter of site
|
|
31
|
+
* size, not of anything the caller did wrong — dying on it would throw away all
|
|
32
|
+
* the work done so far.
|
|
33
|
+
*/
|
|
34
|
+
const request = async (auth, url, init = {}) => {
|
|
35
|
+
const { pathname } = new URL(url);
|
|
36
|
+
const method = init.method ?? 'GET';
|
|
37
|
+
for (let attempt = 0;; attempt++) {
|
|
38
|
+
// eslint-disable-next-line no-await-in-loop
|
|
39
|
+
const resp = await fetch(url, {
|
|
40
|
+
...init,
|
|
41
|
+
headers: { Authorization: `Bearer ${auth.apiKey}`, ...init.headers },
|
|
42
|
+
});
|
|
43
|
+
// eslint-disable-next-line no-await-in-loop
|
|
44
|
+
if (resp.ok)
|
|
45
|
+
return (await resp.json());
|
|
46
|
+
if (resp.status === 429 && attempt < RATE_LIMIT_RETRIES) {
|
|
47
|
+
const retryAfter = Number(resp.headers.get('retry-after'));
|
|
48
|
+
// eslint-disable-next-line no-await-in-loop
|
|
49
|
+
await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : RATE_LIMIT_FALLBACK_MS);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
// eslint-disable-next-line no-await-in-loop
|
|
53
|
+
const body = await resp.text().catch(() => '');
|
|
54
|
+
throw new Error(`Webflow ${method} ${pathname} failed (${resp.status} ${resp.statusText})${body ? `: ${body.slice(0, 300)}` : ''}`);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Every asset in the site's asset library, oldest page first.
|
|
59
|
+
*
|
|
60
|
+
* @yields each asset, one page of 100 at a time.
|
|
61
|
+
*/
|
|
62
|
+
export async function* getAssets(auth, limit = 100) {
|
|
63
|
+
for (let page = 0;; page++) {
|
|
64
|
+
const url = new URL(`${API}/sites/${auth.siteId}/assets`);
|
|
65
|
+
url.searchParams.set('offset', `${page * limit}`);
|
|
66
|
+
url.searchParams.set('limit', `${limit}`);
|
|
67
|
+
// eslint-disable-next-line no-await-in-loop
|
|
68
|
+
const data = await request(auth, url);
|
|
69
|
+
yield* data.assets;
|
|
70
|
+
if (page + 1 >= Math.ceil(data.pagination.total / data.pagination.limit))
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Every CMS collection on the site. Not paginated by Webflow. */
|
|
75
|
+
export const getCollections = async (auth) => {
|
|
76
|
+
const data = await request(auth, `${API}/sites/${auth.siteId}/collections`);
|
|
77
|
+
return data.collections;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Every item in a collection.
|
|
81
|
+
*
|
|
82
|
+
* Reads from STAGING by default (the `/live` endpoint is opt-in) to match
|
|
83
|
+
* `updateCollectionItem`, which also writes to staging — so a run's changes
|
|
84
|
+
* need publishing in Webflow before they show on the live site.
|
|
85
|
+
*
|
|
86
|
+
* @yields each item in the collection.
|
|
87
|
+
*/
|
|
88
|
+
export async function* getCollectionItems(auth, collectionId, { limit = 100, staging = true } = {}) {
|
|
89
|
+
for (let page = 0;; page++) {
|
|
90
|
+
const url = new URL(`${API}/collections/${collectionId}/items${staging ? '' : '/live'}`);
|
|
91
|
+
url.searchParams.set('offset', `${page * limit}`);
|
|
92
|
+
url.searchParams.set('limit', `${limit}`);
|
|
93
|
+
// eslint-disable-next-line no-await-in-loop
|
|
94
|
+
const data = await request(auth, url);
|
|
95
|
+
yield* data.items;
|
|
96
|
+
if (page + 1 >= Math.ceil(data.pagination.total / data.pagination.limit))
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Set the alt text on a site asset. */
|
|
101
|
+
export const updateAssetAltText = async (auth, assetId, altText) => {
|
|
102
|
+
await request(auth, `${API}/assets/${assetId}`, {
|
|
103
|
+
body: JSON.stringify({ altText }),
|
|
104
|
+
headers: { 'Content-Type': 'application/json' },
|
|
105
|
+
method: 'PATCH',
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
/** Patch one item's fieldData. Only the fields present in `fieldData` are touched. */
|
|
109
|
+
export const updateCollectionItem = async (auth, collectionId, itemId, fieldData) => {
|
|
110
|
+
await request(auth, `${API}/collections/${collectionId}/items`, {
|
|
111
|
+
body: JSON.stringify({ items: [{ fieldData, id: itemId }] }),
|
|
112
|
+
headers: { 'Content-Type': 'application/json' },
|
|
113
|
+
method: 'PATCH',
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* Webflow hands back untyped `fieldData`, so image fields are duck-typed: a
|
|
118
|
+
* single image is an object with a `url`, a multi-image field is an array of
|
|
119
|
+
* those (an empty array counts — it is still an image field, just empty).
|
|
120
|
+
*/
|
|
121
|
+
export const isImageField = (value) => typeof value === 'object' && value !== null && !Array.isArray(value) && 'url' in value;
|
|
122
|
+
export const isImagesField = (value) => Array.isArray(value) && value.every((entry) => isImageField(entry));
|
package/dist/lib/workspace.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { RpcConfig } from './rpc.js';
|
|
2
1
|
/**
|
|
3
2
|
* Mutagen-backed workspace helpers.
|
|
4
3
|
*
|
|
@@ -23,9 +22,10 @@ import type { RpcConfig } from './rpc.js';
|
|
|
23
22
|
* Mutagen auto-deploys its agent to the remote over that same SSH connection;
|
|
24
23
|
* nothing to install on the remote by hand.
|
|
25
24
|
*
|
|
26
|
-
* The
|
|
25
|
+
* The reverse tunnels that remain are optional and unrelated to files: with
|
|
27
26
|
* --devtools we open `ssh -R` so the remote's chrome-devtools MCP can reach the
|
|
28
|
-
* caller's LOCAL browser
|
|
27
|
+
* caller's LOCAL browser, and with --with-tool we open one so the remote's
|
|
28
|
+
* fnd-tools MCP can reach the tools server running on the caller.
|
|
29
29
|
*/
|
|
30
30
|
export interface SshTarget {
|
|
31
31
|
host: string;
|
|
@@ -40,6 +40,17 @@ export interface PortPair {
|
|
|
40
40
|
}
|
|
41
41
|
/** For --devtools: `local` is the browser's remote-debugging port on the caller's machine. */
|
|
42
42
|
export type DevtoolsPorts = PortPair;
|
|
43
|
+
/**
|
|
44
|
+
* The workspace tools MCP (--with-tool): the reverse-tunnel port pair for the
|
|
45
|
+
* loopback MCP server started on the caller, plus the raw `--with-tool` values
|
|
46
|
+
* for the plan display. Undefined when no tool was requested.
|
|
47
|
+
*/
|
|
48
|
+
export interface ToolsMcp {
|
|
49
|
+
/** The `--with-tool` values selected, verbatim, for the plan summary. */
|
|
50
|
+
names: string[];
|
|
51
|
+
/** `local` = the caller's MCP server port; `remote` = the port opened on the workspace host. */
|
|
52
|
+
port: PortPair;
|
|
53
|
+
}
|
|
43
54
|
/**
|
|
44
55
|
* Which side wins when the same path changed on both ends since the last sync.
|
|
45
56
|
* `remote` = this server (the box where the workspace shell runs); `local` = the
|
|
@@ -61,12 +72,12 @@ export interface WorkspaceContext {
|
|
|
61
72
|
localUser: string;
|
|
62
73
|
/** Where the mirror lives on the REMOTE, e.g. /home/fnd/<localUser>/<localDirName>. */
|
|
63
74
|
remoteDir: string;
|
|
64
|
-
/** Local-command RPC server + tunnel, when --rpc was passed; undefined otherwise. */
|
|
65
|
-
rpc?: RpcConfig;
|
|
66
75
|
/** Which endpoint wins conflicts (the Mutagen alpha in two-way-resolved); undefined flags conflicts instead. */
|
|
67
76
|
source?: SyncSource;
|
|
68
77
|
/** Unique Mutagen session name for this workspace. */
|
|
69
78
|
syncName: string;
|
|
79
|
+
/** Tools MCP server + tunnel, when --with-tool was passed; undefined otherwise. */
|
|
80
|
+
tools?: ToolsMcp;
|
|
70
81
|
}
|
|
71
82
|
export declare const DEFAULT_MOUNT_BASE = "/home/fnd";
|
|
72
83
|
/** Parse a `user@host` string, throwing a friendly error otherwise. */
|
|
@@ -91,8 +102,8 @@ export declare const buildContext: (opts: {
|
|
|
91
102
|
devtools?: DevtoolsPorts;
|
|
92
103
|
ignoreVcs?: boolean;
|
|
93
104
|
remoteBase: string;
|
|
94
|
-
rpc?: RpcConfig;
|
|
95
105
|
source?: SyncSource;
|
|
106
|
+
tools?: ToolsMcp;
|
|
96
107
|
}) => WorkspaceContext;
|
|
97
108
|
/**
|
|
98
109
|
* Translate one .gitignore line into Mutagen ignore patterns. `base` is the
|
|
@@ -136,10 +147,18 @@ export interface RemoteCleanupOptions {
|
|
|
136
147
|
deleteRemoteDir?: boolean;
|
|
137
148
|
/** Strip this project's chrome-devtools MCP entry — only when the workspace registered one (--devtools). */
|
|
138
149
|
removeDevtoolsMcp?: boolean;
|
|
139
|
-
/** Strip this project's
|
|
140
|
-
|
|
150
|
+
/** Strip this project's fnd-tools MCP entry — only when the workspace registered one (--with-tool). */
|
|
151
|
+
removeToolsMcp?: boolean;
|
|
141
152
|
}
|
|
142
|
-
/**
|
|
153
|
+
/**
|
|
154
|
+
* Run the remote-side teardown over a fresh ssh connection. Forces a remote PTY
|
|
155
|
+
* with `-tt`: the MCP-remove step runs under an *interactive* login shell
|
|
156
|
+
* (`$SHELL -lic`, so `claude2` aliases in rc files resolve — see
|
|
157
|
+
* `claudeMcpRemoveScript`), and an interactive bash without a PTY prints
|
|
158
|
+
* "cannot set terminal process group / no job control in this shell". `-tt`
|
|
159
|
+
* forces allocation even when this cleanup runs without a local TTY, mirroring
|
|
160
|
+
* the `-t` the interactive session ssh uses.
|
|
161
|
+
*/
|
|
143
162
|
export declare const runRemoteCleanup: (target: string, remoteDir: string, opts?: RemoteCleanupOptions) => Promise<number>;
|
|
144
163
|
/**
|
|
145
164
|
* The remote-side teardown script. Runs from inside the workspace dir so the
|
|
@@ -161,7 +180,7 @@ export declare const hasMutagen: () => boolean;
|
|
|
161
180
|
* winner. When a `source` is given, it becomes the Mutagen alpha endpoint and
|
|
162
181
|
* the mode switches to two-way-resolved (alpha always wins conflicts), so
|
|
163
182
|
* `--source remote` puts the server first and `--source local` puts this
|
|
164
|
-
* machine first. Labels let `workspace cleanup` find and terminate orphans.
|
|
183
|
+
* machine first. Labels let `workspace --cleanup` find and terminate orphans.
|
|
165
184
|
* `ctx.ignores` (from --ignore-vcs) excludes gitignored paths from the sync so
|
|
166
185
|
* each side keeps its own build artifacts and platform-specific binaries.
|
|
167
186
|
*/
|
package/dist/lib/workspace.js
CHANGED
|
@@ -60,9 +60,9 @@ export const buildContext = (opts) => {
|
|
|
60
60
|
localDirName,
|
|
61
61
|
localUser,
|
|
62
62
|
remoteDir,
|
|
63
|
-
rpc: opts.rpc,
|
|
64
63
|
source: opts.source,
|
|
65
64
|
syncName: buildSyncName(localDirName),
|
|
65
|
+
tools: opts.tools,
|
|
66
66
|
};
|
|
67
67
|
};
|
|
68
68
|
/**
|
|
@@ -155,8 +155,31 @@ export const collectVcsIgnores = (rootDir) => {
|
|
|
155
155
|
export const shQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`;
|
|
156
156
|
/** MCP server name registered for the workspace's chrome-devtools tunnel. */
|
|
157
157
|
const DEVTOOLS_MCP_NAME = 'chrome-devtools';
|
|
158
|
-
/** MCP server name registered for the --
|
|
159
|
-
const
|
|
158
|
+
/** MCP server name registered for the --with-tool tools tunnel. */
|
|
159
|
+
const TOOLS_MCP_NAME = 'fnd-tools';
|
|
160
|
+
/**
|
|
161
|
+
* The `claude` CLIs an MCP entry is registered with / removed from. `claude2` is
|
|
162
|
+
* an overflow instance some remotes run alongside `claude`; both need the same
|
|
163
|
+
* project-local MCP config so whichever the user opens sees the workspace tools.
|
|
164
|
+
* Each is guarded by its own `command -v` — a remote without a given CLI just
|
|
165
|
+
* skips it. Keep add and remove over the same list so cleanup is complete.
|
|
166
|
+
*
|
|
167
|
+
* These are emitted as LITERAL command words, never `for cli in …; do "$cli" …`.
|
|
168
|
+
* On some remotes `claude2` is a shell *alias* (defined in ~/.bashrc / ~/.zshrc),
|
|
169
|
+
* and aliases are only expanded when the command word is a literal read by the
|
|
170
|
+
* parser — an alias never expands from a variable like `"$cli"`. Alias support is
|
|
171
|
+
* also why the add/remove blocks run under an *interactive* login shell (see the
|
|
172
|
+
* `-lic` note below): rc files, where the alias lives, are sourced only for
|
|
173
|
+
* interactive shells.
|
|
174
|
+
*/
|
|
175
|
+
const CLAUDE_CLIS = ['claude', 'claude2'];
|
|
176
|
+
/**
|
|
177
|
+
* Emit a guarded per-CLI block for each entry in {@link CLAUDE_CLIS}. `cli` is
|
|
178
|
+
* interpolated literally (not via a variable) so a shell-alias `claude2` still
|
|
179
|
+
* expands. `command -v` gates each one so a remote missing a CLI just skips it;
|
|
180
|
+
* `body(cli)` returns the lines to run inside the guard.
|
|
181
|
+
*/
|
|
182
|
+
const forEachClaudeCli = (body) => CLAUDE_CLIS.flatMap((cli) => [`if command -v ${cli} >/dev/null 2>&1; then`, ...body(cli).map((l) => ` ${l}`), 'fi']);
|
|
160
183
|
/**
|
|
161
184
|
* Bash lines (run on the REMOTE, from inside the workspace dir) that register the
|
|
162
185
|
* chrome-devtools MCP with the `claude` CLI. Local scope keys off the current
|
|
@@ -164,41 +187,44 @@ const RPC_MCP_NAME = 'local-shell';
|
|
|
164
187
|
* A prior entry is cleared first so a re-connect after a crashed session is
|
|
165
188
|
* idempotent. Skips gracefully if the claude CLI is missing.
|
|
166
189
|
*
|
|
167
|
-
* The block runs inside
|
|
168
|
-
* a non-login ssh command whose PATH lacks the
|
|
169
|
-
* login profiles add, so a bare `command -v
|
|
170
|
-
*
|
|
190
|
+
* The block runs inside an *interactive* login shell (`$SHELL -lic`). Login (`-l`)
|
|
191
|
+
* because the outer script arrives as a non-login ssh command whose PATH lacks the
|
|
192
|
+
* node/nvm/volta/Homebrew dirs that login profiles add, so a bare `command -v
|
|
193
|
+
* claude` would miss an installed CLI. Interactive (`-i`) because on some remotes
|
|
194
|
+
* `claude2` is a shell alias defined in ~/.bashrc / ~/.zshrc, and those rc files
|
|
195
|
+
* are sourced only for interactive shells — a plain `-lc` command shell never sees
|
|
196
|
+
* the alias. The `ssh -t` PTY is what lets `-i` run without job-control warnings.
|
|
171
197
|
*/
|
|
172
198
|
const claudeDevtoolsAddScript = (remotePort, okMessage) => {
|
|
173
199
|
const body = [
|
|
174
|
-
'
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
200
|
+
'configured=""',
|
|
201
|
+
...forEachClaudeCli((cli) => [
|
|
202
|
+
`${cli} mcp remove ${DEVTOOLS_MCP_NAME} >/dev/null 2>&1 || true`,
|
|
203
|
+
`${cli} mcp add ${DEVTOOLS_MCP_NAME} -- npx -y chrome-devtools-mcp@latest --browserUrl http://127.0.0.1:${remotePort}`,
|
|
204
|
+
'configured=1',
|
|
205
|
+
]),
|
|
206
|
+
`if [ -n "$configured" ]; then echo ${shQuote(okMessage)}; else echo "WARNING: claude CLI not found on the remote; skipped chrome-devtools MCP config." >&2; fi`,
|
|
181
207
|
].join('\n');
|
|
182
|
-
return [`"\${SHELL:-bash}" -
|
|
208
|
+
return [`"\${SHELL:-bash}" -lic ${shQuote(body)}`];
|
|
183
209
|
};
|
|
184
210
|
/**
|
|
185
211
|
* Bash lines (run on the REMOTE, from inside the workspace dir) that register the
|
|
186
|
-
* --
|
|
212
|
+
* --with-tool fnd-tools MCP with the `claude` CLI as a Streamable HTTP server. The
|
|
187
213
|
* URL points at the reverse-tunnelled port, which the workspace's `ssh -R`
|
|
188
|
-
* forwards back to the
|
|
214
|
+
* forwards back to the tools server on the calling machine. Same login-shell and
|
|
189
215
|
* idempotency reasoning as `claudeDevtoolsAddScript`.
|
|
190
216
|
*/
|
|
191
|
-
const
|
|
217
|
+
const claudeToolsAddScript = (remotePort, okMessage) => {
|
|
192
218
|
const body = [
|
|
193
|
-
'
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
219
|
+
'configured=""',
|
|
220
|
+
...forEachClaudeCli((cli) => [
|
|
221
|
+
`${cli} mcp remove ${TOOLS_MCP_NAME} >/dev/null 2>&1 || true`,
|
|
222
|
+
`${cli} mcp add --transport http ${TOOLS_MCP_NAME} http://127.0.0.1:${remotePort}/mcp`,
|
|
223
|
+
'configured=1',
|
|
224
|
+
]),
|
|
225
|
+
`if [ -n "$configured" ]; then echo ${shQuote(okMessage)}; else echo "WARNING: claude CLI not found on the remote; skipped fnd-tools MCP config." >&2; fi`,
|
|
200
226
|
].join('\n');
|
|
201
|
-
return [`"\${SHELL:-bash}" -
|
|
227
|
+
return [`"\${SHELL:-bash}" -lic ${shQuote(body)}`];
|
|
202
228
|
};
|
|
203
229
|
/**
|
|
204
230
|
* Bash lines (run on the REMOTE, from inside the workspace dir) that remove an
|
|
@@ -208,12 +234,13 @@ const claudeRpcAddScript = (remotePort, okMessage) => {
|
|
|
208
234
|
* `claudeDevtoolsAddScript`.
|
|
209
235
|
*/
|
|
210
236
|
const claudeMcpRemoveScript = (name) => {
|
|
211
|
-
const body = [
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
237
|
+
const body = forEachClaudeCli((cli) => [
|
|
238
|
+
// Tolerate failure: if the entry was never registered (or already gone) the
|
|
239
|
+
// remove exits non-zero — that's success for us, so never let it abort the
|
|
240
|
+
// rest of the teardown (dir deletion, the other CLI, …).
|
|
241
|
+
`${cli} mcp remove ${name} >/dev/null 2>&1 || true`,
|
|
242
|
+
]).join('\n');
|
|
243
|
+
return [`"\${SHELL:-bash}" -lic ${shQuote(body)}`];
|
|
217
244
|
};
|
|
218
245
|
/**
|
|
219
246
|
* The bash script the remote runs for the interactive session. Mutagen already
|
|
@@ -225,8 +252,8 @@ export const buildRemoteScript = (ctx) => {
|
|
|
225
252
|
const devtoolsSetup = ctx.devtools
|
|
226
253
|
? claudeDevtoolsAddScript(ctx.devtools.remote, `Configured chrome-devtools MCP for this workspace (browser via 127.0.0.1:${ctx.devtools.remote}).`)
|
|
227
254
|
: [];
|
|
228
|
-
const
|
|
229
|
-
?
|
|
255
|
+
const toolsSetup = ctx.tools
|
|
256
|
+
? claudeToolsAddScript(ctx.tools.port.remote, `Configured fnd-tools MCP for this workspace (${ctx.tools.names.join(', ')} on the calling machine via 127.0.0.1:${ctx.tools.port.remote}).`)
|
|
230
257
|
: [];
|
|
231
258
|
return [
|
|
232
259
|
'set -u',
|
|
@@ -236,15 +263,23 @@ export const buildRemoteScript = (ctx) => {
|
|
|
236
263
|
'cd "$DIR" || { echo "ERROR: could not enter $DIR" >&2; exit 1; }',
|
|
237
264
|
// Register the MCPs from inside $DIR: `claude mcp add` local scope keys off cwd.
|
|
238
265
|
...devtoolsSetup,
|
|
239
|
-
...
|
|
266
|
+
...toolsSetup,
|
|
240
267
|
'echo "Workspace ready at $DIR — files sync in the background (exit to stop syncing)."',
|
|
241
268
|
// eslint-disable-next-line no-template-curly-in-string -- shell parameter expansion, not a JS template
|
|
242
269
|
'"${SHELL:-bash}" -l',
|
|
243
270
|
].join('\n');
|
|
244
271
|
};
|
|
245
|
-
/**
|
|
272
|
+
/**
|
|
273
|
+
* Run the remote-side teardown over a fresh ssh connection. Forces a remote PTY
|
|
274
|
+
* with `-tt`: the MCP-remove step runs under an *interactive* login shell
|
|
275
|
+
* (`$SHELL -lic`, so `claude2` aliases in rc files resolve — see
|
|
276
|
+
* `claudeMcpRemoveScript`), and an interactive bash without a PTY prints
|
|
277
|
+
* "cannot set terminal process group / no job control in this shell". `-tt`
|
|
278
|
+
* forces allocation even when this cleanup runs without a local TTY, mirroring
|
|
279
|
+
* the `-t` the interactive session ssh uses.
|
|
280
|
+
*/
|
|
246
281
|
export const runRemoteCleanup = (target, remoteDir, opts = {}) => new Promise((resolve, reject) => {
|
|
247
|
-
const child = spawn('ssh', [target, buildCleanupScript(remoteDir, opts)], { stdio: 'inherit' });
|
|
282
|
+
const child = spawn('ssh', ['-tt', target, buildCleanupScript(remoteDir, opts)], { stdio: 'inherit' });
|
|
248
283
|
child.once('error', reject);
|
|
249
284
|
child.once('close', (code) => resolve(code ?? 0));
|
|
250
285
|
});
|
|
@@ -261,7 +296,7 @@ export const buildCleanupScript = (remoteDir, opts = {}) => [
|
|
|
261
296
|
`DIR=${shQuote(remoteDir)}`,
|
|
262
297
|
'cd "$DIR" 2>/dev/null || { echo "Nothing to clean up: $DIR is gone." >&2; exit 0; }',
|
|
263
298
|
...(opts.removeDevtoolsMcp ? claudeMcpRemoveScript(DEVTOOLS_MCP_NAME) : []),
|
|
264
|
-
...(opts.
|
|
299
|
+
...(opts.removeToolsMcp ? claudeMcpRemoveScript(TOOLS_MCP_NAME) : []),
|
|
265
300
|
// Remove the workspace dir last: cd out first so we don't rm the cwd out from
|
|
266
301
|
// under the shell, then delete it. Only when explicitly requested.
|
|
267
302
|
...(opts.deleteRemoteDir
|
|
@@ -287,7 +322,7 @@ export const hasMutagen = () => {
|
|
|
287
322
|
* winner. When a `source` is given, it becomes the Mutagen alpha endpoint and
|
|
288
323
|
* the mode switches to two-way-resolved (alpha always wins conflicts), so
|
|
289
324
|
* `--source remote` puts the server first and `--source local` puts this
|
|
290
|
-
* machine first. Labels let `workspace cleanup` find and terminate orphans.
|
|
325
|
+
* machine first. Labels let `workspace --cleanup` find and terminate orphans.
|
|
291
326
|
* `ctx.ignores` (from --ignore-vcs) excludes gitignored paths from the sync so
|
|
292
327
|
* each side keeps its own build artifacts and platform-specific binaries.
|
|
293
328
|
*/
|