@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
|
@@ -1,15 +1,15 @@
|
|
|
1
|
+
import { findShopifyBin, generateClaudeMd, GITIGNORE_CONTENT, initPromptPath, run, SHOPIFYIGNORE_CONTENT, which, } from '#lib/scaffold.js';
|
|
1
2
|
import { Args, Command } from '@oclif/core';
|
|
2
3
|
import chalk from 'chalk';
|
|
3
4
|
import { spawnSync } from 'node:child_process';
|
|
4
5
|
import { existsSync, writeFileSync } from 'node:fs';
|
|
5
6
|
import { basename, dirname, join } from 'node:path';
|
|
6
7
|
import { simpleGit } from 'simple-git';
|
|
7
|
-
import { findShopifyBin, generateClaudeMd, GITIGNORE_CONTENT, initPromptPath, run, SHOPIFYIGNORE_CONTENT, which, } from '../lib/scaffold.js';
|
|
8
8
|
export default class CreateProject extends Command {
|
|
9
9
|
static args = {
|
|
10
10
|
shop: Args.string({ description: 'Shopify store handle — pulls the live theme from <shop>.myshopify.com (omit to skip)', required: false }),
|
|
11
11
|
};
|
|
12
|
-
static description = 'Scaffold a new client project: git on branch production, ignore files, Shopify theme pull, a Claude-generated CLAUDE.md, then a private GitHub repo under the FireNet-Designs org.\n\nRequires the claude CLI (npm install -g @anthropic-ai/claude-code) for the CLAUDE.md step.\n\nGitHub auth comes from YOUR environment — run `gh auth login` once, or export GH_TOKEN in your shell profile. This CLI never stores credentials. Override the org with FND_GH_ORG.';
|
|
12
|
+
static description = 'Scaffold a new client project: git on branch production, ignore files, Shopify theme pull, a Claude-generated CLAUDE.md, then a private GitHub repo under the FireNet-Designs org with production and staging branches pushed.\n\nRequires the claude CLI (npm install -g @anthropic-ai/claude-code) for the CLAUDE.md step.\n\nGitHub auth comes from YOUR environment — run `gh auth login` once, or export GH_TOKEN in your shell profile. This CLI never stores credentials. Override the org with FND_GH_ORG.';
|
|
13
13
|
static examples = [
|
|
14
14
|
'<%= config.bin %> <%= command.id %>',
|
|
15
15
|
'<%= config.bin %> <%= command.id %> my-store',
|
|
@@ -106,17 +106,60 @@ export default class CreateProject extends Command {
|
|
|
106
106
|
if (createCode !== 0) {
|
|
107
107
|
this.error('gh repo create failed — see output above', { code: '1' });
|
|
108
108
|
}
|
|
109
|
+
// gh pointed 'origin' at the URL GitHub returned, which carries the name
|
|
110
|
+
// the repo was ACTUALLY created under — GitHub normalizes names
|
|
111
|
+
// server-side ('my client' → 'my-client'). Recover it before forcing SSH
|
|
112
|
+
// so the remote, the default-branch edit, and the printed link all track
|
|
113
|
+
// the real repo, not the raw directory name.
|
|
114
|
+
const originUrl = (await git.raw(['remote', 'get-url', 'origin']).catch(() => '')).trim();
|
|
115
|
+
const canonicalRepo = /github\.com[/:](.+?)(?:\.git)?$/.exec(originUrl)?.[1] ?? repo;
|
|
109
116
|
// Force SSH so the push authenticates with the user's key, not the
|
|
110
117
|
// create-only token.
|
|
111
|
-
await git.raw(['remote', 'set-url', 'origin', `git@github.com:${
|
|
118
|
+
await git.raw(['remote', 'set-url', 'origin', `git@github.com:${canonicalRepo}.git`]);
|
|
112
119
|
this.log(chalk.blue("⬆️ Pushing 'production' to origin over SSH…"));
|
|
113
120
|
const pushCode = await run('git', ['push', '-u', 'origin', 'production']);
|
|
114
121
|
if (pushCode === 0) {
|
|
115
|
-
spawnSync('gh', ['repo', 'edit',
|
|
116
|
-
this.log(chalk.green(`✅ Created + pushed. origin = git@github.com:${
|
|
122
|
+
spawnSync('gh', ['repo', 'edit', canonicalRepo, '--default-branch', 'production'], { stdio: 'ignore' });
|
|
123
|
+
this.log(chalk.green(`✅ Created + pushed. origin = git@github.com:${canonicalRepo}.git`));
|
|
124
|
+
// 6. Staging branch cut explicitly from 'production' — HEAD isn't a safe
|
|
125
|
+
// start point because a pre-existing repo (init step skipped) may be
|
|
126
|
+
// checked out elsewhere. No checkout, so the working tree stays put.
|
|
127
|
+
// A pre-existing 'staging' is pushed as-is (force-resetting it could
|
|
128
|
+
// destroy work), with divergence from production called out instead of
|
|
129
|
+
// silently published. try/catch so a git failure here can't swallow the
|
|
130
|
+
// URL line below — the repo exists either way.
|
|
131
|
+
try {
|
|
132
|
+
const { all: localBranches } = await git.branchLocal();
|
|
133
|
+
const stagingExisted = localBranches.includes('staging');
|
|
134
|
+
if (!stagingExisted) {
|
|
135
|
+
await git.raw(['branch', 'staging', 'production']);
|
|
136
|
+
}
|
|
137
|
+
this.log(chalk.blue("⬆️ Pushing 'staging' to origin over SSH…"));
|
|
138
|
+
const stagingPushCode = await run('git', ['push', '-u', 'origin', 'staging']);
|
|
139
|
+
if (stagingPushCode !== 0) {
|
|
140
|
+
this.warn("push of 'staging' failed — create it manually: git push -u origin staging");
|
|
141
|
+
}
|
|
142
|
+
else if (stagingExisted) {
|
|
143
|
+
this.log(chalk.green("✅ pre-existing 'staging' branch pushed"));
|
|
144
|
+
const [stagingSha, productionSha] = (await git.raw(['rev-parse', 'staging', 'production'])).trim().split('\n');
|
|
145
|
+
if (stagingSha !== productionSha) {
|
|
146
|
+
this.warn("'staging' does not match 'production' — it was pushed at its current commit");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
this.log(chalk.green("✅ 'staging' branch created + pushed"));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
this.warn("could not create 'staging' — create it manually: git branch staging production && git push -u origin staging");
|
|
155
|
+
}
|
|
117
156
|
}
|
|
118
157
|
else {
|
|
119
158
|
this.warn(`push failed — check your SSH access to the ${org} org`);
|
|
120
159
|
}
|
|
160
|
+
// https form, not the SSH remote, and printed even when a push failed (the
|
|
161
|
+
// repo itself exists by now) — this line is for copy-pasting into Jira
|
|
162
|
+
// tickets and browsers.
|
|
163
|
+
this.log(`🔗 GitHub: ${chalk.green(`https://github.com/${canonicalRepo}`)}`);
|
|
121
164
|
}
|
|
122
165
|
}
|
|
@@ -3,18 +3,35 @@ export default class Workspace extends Command {
|
|
|
3
3
|
static description: string;
|
|
4
4
|
static examples: string[];
|
|
5
5
|
static flags: {
|
|
6
|
+
cleanup: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
6
7
|
'delete-remote-dir': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
8
|
devtools: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
9
|
'ignore-vcs': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
9
10
|
'remote-base': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
-
|
|
11
|
+
'site-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
12
|
source: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
13
|
ssh: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
'with-tool': import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
15
|
};
|
|
14
16
|
run(): Promise<void>;
|
|
15
|
-
/** Verify this machine can drive the sync before we connect. */
|
|
17
|
+
/** Verify this machine can drive the sync — and each tool's prerequisites — before we connect. */
|
|
16
18
|
private preflight;
|
|
17
19
|
private printPlan;
|
|
20
|
+
/**
|
|
21
|
+
* Tear down leftovers from a session that dropped before cleaning up (--cleanup).
|
|
22
|
+
* Terminates this directory's Mutagen sync sessions locally, then reaches the
|
|
23
|
+
* remote to strip the MCP entries the matching flags imply and, with
|
|
24
|
+
* --delete-remote-dir, remove the synced dir. devtools/rpc are read for
|
|
25
|
+
* presence only — their port values are irrelevant to removal.
|
|
26
|
+
*/
|
|
27
|
+
private runCleanup;
|
|
18
28
|
/** Run the interactive ssh session, inheriting the TTY so the remote shell is fully interactive. */
|
|
19
29
|
private runSsh;
|
|
30
|
+
/**
|
|
31
|
+
* Best-effort: reach back to the remote after the session to strip any MCP
|
|
32
|
+
* config this run registered and, with --delete-remote-dir, remove the synced
|
|
33
|
+
* dir. No-op when the session left nothing behind to clean. A failure here is
|
|
34
|
+
* logged, not thrown — the local sync is already torn down by this point.
|
|
35
|
+
*/
|
|
36
|
+
private teardownRemote;
|
|
20
37
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { collectToolSpecs, resolveWorkspaceTools, withToolUsage } from '#lib/mcp/registry.js';
|
|
2
|
+
import { startMcpServer } from '#lib/mcp/server.js';
|
|
3
|
+
import { reconcileShopifyScopes } from '#lib/mcp/tools/shopify-common.js';
|
|
4
|
+
import { browserDebugInstructions, buildContext, buildMutagenCreateArgs, buildMutagenFlushArgs, buildMutagenTerminateArgs, buildMutagenTerminateSelectorArgs, buildRemoteScript, DEFAULT_MOUNT_BASE, hasMutagen, hasSshClient, isLocalDebugPortLive, mutagenInstallInstructions, parsePortPair, parseSshTarget, runMutagen, runRemoteCleanup, slugify, } from '#lib/workspace.js';
|
|
1
5
|
import { Command, Flags } from '@oclif/core';
|
|
2
6
|
import chalk from 'chalk';
|
|
3
7
|
import { spawn } from 'node:child_process';
|
|
4
|
-
import {
|
|
5
|
-
import { browserDebugInstructions, buildContext, buildMutagenCreateArgs, buildMutagenFlushArgs, buildMutagenTerminateArgs, buildRemoteScript, DEFAULT_MOUNT_BASE, hasMutagen, hasSshClient, isLocalDebugPortLive, mutagenInstallInstructions, parsePortPair, parseSshTarget, runMutagen, runRemoteCleanup, } from '../../lib/workspace.js';
|
|
8
|
+
import { basename } from 'node:path';
|
|
6
9
|
export default class Workspace extends Command {
|
|
7
10
|
static description = 'Open a remote workspace: two-way sync the current directory to a remote Linux box with Mutagen and drop into a shell there, tearing the sync down on exit.\n\nBoth sides keep a real copy on local disk and only deltas cross the network, so the remote reads files at native speed. Conflicting edits on both ends are flagged rather than silently overwritten; pass --source to auto-resolve them in favour of one side. Mutagen connects the normal direction (this machine → remote over SSH) and auto-deploys its agent to the remote, so no local SSH server, reverse tunnel, or authorized_keys trust is required. You need the Mutagen CLI installed on THIS machine.';
|
|
8
11
|
static examples = [
|
|
@@ -12,11 +15,19 @@ export default class Workspace extends Command {
|
|
|
12
15
|
'<%= config.bin %> <%= command.id %> --ssh user@host --ignore-vcs',
|
|
13
16
|
'<%= config.bin %> <%= command.id %> --ssh user@host --devtools 9222',
|
|
14
17
|
'<%= config.bin %> <%= command.id %> --ssh user@host --devtools 9333:9222',
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
'<%= config.bin %> <%= command.id %> --ssh user@host --with-tool shopify-file-upload --site-id mystore',
|
|
19
|
+
'<%= config.bin %> <%= command.id %> --ssh user@host --with-tool shopify-file-upload --with-tool shopify-file-search --with-tool "shopify-file-delete[ask]" --site-id mystore',
|
|
20
|
+
'<%= config.bin %> <%= command.id %> --ssh user@host --with-tool shopify-file-upload --with-tool shopify-file-replace --with-tool shopify-file-delete --site-id mystore.myshopify.com',
|
|
21
|
+
'<%= config.bin %> <%= command.id %> --ssh user@host --with-tool "shopify-execute[scopes=read_products+read_orders]" --site-id mystore',
|
|
22
|
+
'<%= config.bin %> <%= command.id %> --ssh user@host --with-tool "shopify-execute[ask,scopes=all]" --site-id mystore',
|
|
17
23
|
'<%= config.bin %> <%= command.id %> --ssh user@host --delete-remote-dir',
|
|
24
|
+
'<%= config.bin %> <%= command.id %> --ssh user@host --with-tool shopify-file-upload --site-id mystore --cleanup',
|
|
18
25
|
];
|
|
19
26
|
static flags = {
|
|
27
|
+
cleanup: Flags.boolean({
|
|
28
|
+
default: false,
|
|
29
|
+
description: "tear down a leftover session instead of opening one — don't connect, just terminate this directory's Mutagen sync and (using whatever other flags are set) strip the remote --devtools/--with-tool MCP entries and, with --delete-remote-dir, remove the remote dir. Re-run your original command with --cleanup appended after a session that dropped without cleaning up.",
|
|
30
|
+
}),
|
|
20
31
|
'delete-remote-dir': Flags.boolean({
|
|
21
32
|
default: false,
|
|
22
33
|
description: 'on exit, delete the remote workspace directory instead of leaving the synced copy in place',
|
|
@@ -32,8 +43,8 @@ export default class Workspace extends Command {
|
|
|
32
43
|
default: DEFAULT_MOUNT_BASE,
|
|
33
44
|
description: 'base dir on the remote; the workspace lands at <base>/<local-user>/<dir-name>',
|
|
34
45
|
}),
|
|
35
|
-
|
|
36
|
-
description:
|
|
46
|
+
'site-id': Flags.string({
|
|
47
|
+
description: 'the Shopify store (mystore or mystore.myshopify.com) the Shopify --with-tool tools operate on. Required when any Shopify tool (shopify-file-* or shopify-execute) is selected; fixing the store here is a safety boundary — the remote AI cannot target another store.',
|
|
37
48
|
}),
|
|
38
49
|
source: Flags.string({
|
|
39
50
|
description: 'which side wins on conflict: "remote" = this server (where the workspace shell runs), "local" = the machine you ran fnd workspace from. Omit to flag conflicts instead of auto-resolving them.',
|
|
@@ -43,25 +54,63 @@ export default class Workspace extends Command {
|
|
|
43
54
|
description: 'remote to connect to, as user@host',
|
|
44
55
|
required: true,
|
|
45
56
|
}),
|
|
57
|
+
'with-tool': Flags.string({
|
|
58
|
+
description: `expose an fnd tool to Claude on the remote via a loopback MCP server on THIS machine (the one running fnd workspace), reached through a reverse tunnel. Repeatable. Value is <name> or <name>[options]; available: ${withToolUsage()}. e.g. shopify-file-upload (with --site-id mystore) lets the remote AI upload files from the workspace into that Shopify store using your local Shopify CLI; shopify-file-replace and shopify-file-delete add in-place replace and delete; shopify-file-search looks up files by name/size/type/url (metadata only, no image bytes) to dedupe before upload or find oversized images; shopify-execute runs arbitrary Admin GraphQL and REQUIRES a scopes option — shopify-execute[scopes=all] for broad access or shopify-execute[scopes=read_products+write_orders] for an explicit set (which the store is then trimmed to, revoking anything extra, so the AI is held to least privilege). Append ,ask (e.g. shopify-file-delete[ask], shopify-execute[ask,scopes=all]) to make that tool prompt for confirmation before EVERY call, even under the remote's auto-accept/bypass permissions — use it to gate the powerful/destructive tools. Prerequisites (auth, scopes) are checked and set up before connecting.`,
|
|
59
|
+
multiple: true,
|
|
60
|
+
}),
|
|
46
61
|
};
|
|
47
62
|
async run() {
|
|
48
63
|
const { flags } = await this.parse(Workspace);
|
|
49
64
|
const target = parseSshTarget(flags.ssh);
|
|
50
65
|
const target2 = `${target.user}@${target.host}`;
|
|
66
|
+
// --cleanup short-circuits everything: no connection, no sync, just tear down
|
|
67
|
+
// leftovers from a session that dropped before it could clean up. The other
|
|
68
|
+
// flags are reused for *what* to clean up (devtools/with-tool MCP entries, dir),
|
|
69
|
+
// and any that don't matter here are ignored — so `↑` + ` --cleanup` just works.
|
|
70
|
+
if (flags.cleanup) {
|
|
71
|
+
await this.runCleanup(target2, flags);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
51
74
|
const devtools = flags.devtools === undefined ? undefined : parsePortPair(flags.devtools, '--devtools');
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
75
|
+
// Resolve --with-tool selections up front so an unknown tool or a bad argument
|
|
76
|
+
// fails before we touch the network.
|
|
77
|
+
let selections = [];
|
|
78
|
+
try {
|
|
79
|
+
selections = resolveWorkspaceTools(flags['with-tool'] ?? [], { siteId: flags['site-id'] });
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
this.error(error.message, { code: '1' });
|
|
83
|
+
}
|
|
84
|
+
// Run every prerequisite check (ssh/mutagen, the browser debug port, and each
|
|
85
|
+
// tool's own setup — auth, scopes) BEFORE connecting, so nothing that needs
|
|
86
|
+
// interactive setup surprises the user mid-session.
|
|
87
|
+
await this.preflight(devtools, selections);
|
|
88
|
+
// With tools selected, start the loopback MCP server now so we know the port
|
|
89
|
+
// to tunnel and register on the remote. It's closed in the finally below.
|
|
90
|
+
let mcpServer;
|
|
91
|
+
let tools;
|
|
92
|
+
if (selections.length > 0) {
|
|
93
|
+
const specs = collectToolSpecs(selections, { localCwd: process.cwd() });
|
|
94
|
+
try {
|
|
95
|
+
mcpServer = await startMcpServer(specs);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
this.error(`Could not start the local tools MCP server (${error.message}).`, { code: '1' });
|
|
99
|
+
}
|
|
100
|
+
if (devtools && devtools.remote === mcpServer.port) {
|
|
101
|
+
await mcpServer.close().catch(() => { });
|
|
102
|
+
this.error(`--devtools remote port ${devtools.remote} collides with the tools MCP server; use a different --devtools port.`, { code: '1' });
|
|
103
|
+
}
|
|
104
|
+
tools = { names: selections.map((s) => s.raw), port: { local: mcpServer.port, remote: mcpServer.port } };
|
|
55
105
|
}
|
|
56
106
|
const ctx = buildContext({
|
|
57
107
|
cwd: process.cwd(),
|
|
58
108
|
devtools,
|
|
59
109
|
ignoreVcs: flags['ignore-vcs'],
|
|
60
110
|
remoteBase: flags['remote-base'],
|
|
61
|
-
rpc,
|
|
62
111
|
source: flags.source,
|
|
112
|
+
tools,
|
|
63
113
|
});
|
|
64
|
-
await this.preflight(devtools, rpc);
|
|
65
114
|
this.printPlan(ctx, target2);
|
|
66
115
|
// Start the two-way sync. Mutagen auto-deploys its agent to the remote over SSH.
|
|
67
116
|
this.log(chalk.dim('Starting the Mutagen sync session…'));
|
|
@@ -70,56 +119,31 @@ export default class Workspace extends Command {
|
|
|
70
119
|
this.error(`mutagen sync create failed (exit ${createCode}). Check that the remote is reachable over SSH and try again.`, { code: '1' });
|
|
71
120
|
}
|
|
72
121
|
const deleteRemoteDir = flags['delete-remote-dir'];
|
|
73
|
-
let rpcServer;
|
|
74
122
|
let code;
|
|
75
123
|
try {
|
|
124
|
+
if (ctx.tools) {
|
|
125
|
+
this.log(chalk.dim(`Local tools MCP server listening on 127.0.0.1:${ctx.tools.port.local} (${ctx.tools.names.join(', ')}).`));
|
|
126
|
+
}
|
|
76
127
|
// Block until the first full sync lands so the files exist before the shell opens.
|
|
77
128
|
this.log(chalk.dim('Performing the initial sync…'));
|
|
78
129
|
const flushCode = await runMutagen(buildMutagenFlushArgs(ctx.syncName));
|
|
79
130
|
if (flushCode !== 0) {
|
|
80
131
|
this.error(`Initial mutagen sync flush failed (exit ${flushCode}).`, { code: '1' });
|
|
81
132
|
}
|
|
82
|
-
// Start the local command server the --rpc tunnel points back to.
|
|
83
|
-
if (ctx.rpc) {
|
|
84
|
-
try {
|
|
85
|
-
rpcServer = await startRpcServer(ctx.rpc, ctx.localCwd);
|
|
86
|
-
this.log(chalk.dim(`Local RPC command server (${ctx.rpc.shell}) listening on 127.0.0.1:${ctx.rpc.ports.local}.`));
|
|
87
|
-
}
|
|
88
|
-
catch (error) {
|
|
89
|
-
this.error(`Could not start the local RPC server on 127.0.0.1:${ctx.rpc.ports.local} (${error.message}). Is the port already in use?`, { code: '1' });
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
133
|
const script = buildRemoteScript(ctx);
|
|
93
134
|
code = await this.runSsh(target2, script, ctx);
|
|
94
135
|
}
|
|
95
136
|
finally {
|
|
96
|
-
// Best-effort: stop serving
|
|
97
|
-
//
|
|
98
|
-
await
|
|
137
|
+
// Best-effort: stop serving tools, flush the last edits back, then tear the
|
|
138
|
+
// session down.
|
|
139
|
+
await mcpServer?.close().catch(() => { });
|
|
99
140
|
this.log('');
|
|
100
141
|
this.log(chalk.dim('Flushing final changes and stopping the sync…'));
|
|
101
142
|
await runMutagen(buildMutagenFlushArgs(ctx.syncName)).catch(() => 1);
|
|
102
143
|
await runMutagen(buildMutagenTerminateArgs(ctx.syncName)).catch(() => 1);
|
|
103
144
|
// Reach back to the remote if we left MCP config there to remove, or if we
|
|
104
145
|
// were asked to delete the synced directory.
|
|
105
|
-
|
|
106
|
-
const actions = [
|
|
107
|
-
ctx.devtools ? 'removing the remote chrome-devtools MCP config' : undefined,
|
|
108
|
-
ctx.rpc ? 'removing the remote local-shell MCP config' : undefined,
|
|
109
|
-
deleteRemoteDir ? 'deleting the remote dir' : undefined,
|
|
110
|
-
].filter(Boolean);
|
|
111
|
-
this.log(chalk.dim(`${actions.join(' and ')}…`.replace(/^./, (c) => c.toUpperCase())));
|
|
112
|
-
try {
|
|
113
|
-
await runRemoteCleanup(target2, ctx.remoteDir, {
|
|
114
|
-
deleteRemoteDir,
|
|
115
|
-
removeDevtoolsMcp: Boolean(ctx.devtools),
|
|
116
|
-
removeRpcMcp: Boolean(ctx.rpc),
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
catch (error) {
|
|
120
|
-
this.log(chalk.yellow(`Could not reach the remote to clean up (${error.message}).`));
|
|
121
|
-
}
|
|
122
|
-
}
|
|
146
|
+
await this.teardownRemote(ctx, target2, deleteRemoteDir);
|
|
123
147
|
}
|
|
124
148
|
this.log('');
|
|
125
149
|
this.log(code === 0
|
|
@@ -128,8 +152,8 @@ export default class Workspace extends Command {
|
|
|
128
152
|
: '✓ Workspace closed. Sync stopped; the remote copy is left in place.')
|
|
129
153
|
: chalk.yellow(`Session ended with exit code ${code}. Cleanup attempted above.`));
|
|
130
154
|
}
|
|
131
|
-
/** Verify this machine can drive the sync before we connect. */
|
|
132
|
-
async preflight(devtools,
|
|
155
|
+
/** Verify this machine can drive the sync — and each tool's prerequisites — before we connect. */
|
|
156
|
+
async preflight(devtools, selections) {
|
|
133
157
|
if (!hasSshClient()) {
|
|
134
158
|
this.error('No `ssh` client found on PATH. Install OpenSSH client and try again.', { code: '1' });
|
|
135
159
|
}
|
|
@@ -152,12 +176,32 @@ export default class Workspace extends Command {
|
|
|
152
176
|
this.error('Local browser remote-debugging port is required for --devtools. Aborting.', { code: '1' });
|
|
153
177
|
}
|
|
154
178
|
}
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
179
|
+
// Each --with-tool tool checks its own prerequisites here — the same "verify
|
|
180
|
+
// before we connect" contract as the devtools port above. A tool may do
|
|
181
|
+
// interactive setup (e.g. opening the browser for `shopify store auth`), so
|
|
182
|
+
// this runs while the user is still watching, not mid-session. A thrown check
|
|
183
|
+
// aborts before anything connects.
|
|
184
|
+
for (const { config, tool } of selections) {
|
|
185
|
+
try {
|
|
186
|
+
// eslint-disable-next-line no-await-in-loop -- tools set up sequentially; each may prompt interactively
|
|
187
|
+
await tool.preflight(config, (message) => this.log(chalk.dim(message)));
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
this.error(`--with-tool ${tool.name}: ${error.message}`, { code: '1' });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Reconcile the Shopify store's scopes ONCE across all the selected tools:
|
|
194
|
+
// grant the union they need, and — only when shopify-execute is in the run —
|
|
195
|
+
// revoke anything extra so the arbitrary-GraphQL tool is held to least
|
|
196
|
+
// privilege. Runs after the per-tool checks (so a missing CLI has already
|
|
197
|
+
// aborted with a friendly message) and before we connect, so the browser
|
|
198
|
+
// `shopify store auth` happens while the user is watching. A no-op when no
|
|
199
|
+
// Shopify tool is selected.
|
|
200
|
+
try {
|
|
201
|
+
reconcileShopifyScopes(selections, (message) => this.log(chalk.dim(message)));
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
this.error(error.message, { code: '1' });
|
|
161
205
|
}
|
|
162
206
|
}
|
|
163
207
|
printPlan(ctx, target) {
|
|
@@ -175,17 +219,62 @@ export default class Workspace extends Command {
|
|
|
175
219
|
if (ctx.devtools) {
|
|
176
220
|
this.log(` ${chalk.dim('devtools:')} remote 127.0.0.1:${ctx.devtools.remote} → local browser 127.0.0.1:${ctx.devtools.local}`);
|
|
177
221
|
}
|
|
178
|
-
if (ctx.
|
|
179
|
-
this.log(` ${chalk.dim('
|
|
222
|
+
if (ctx.tools) {
|
|
223
|
+
this.log(` ${chalk.dim('tools:')} remote 127.0.0.1:${ctx.tools.port.remote} → local MCP server 127.0.0.1:${ctx.tools.port.local} (${ctx.tools.names.join(', ')})`);
|
|
180
224
|
}
|
|
181
225
|
this.log('');
|
|
182
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* Tear down leftovers from a session that dropped before cleaning up (--cleanup).
|
|
229
|
+
* Terminates this directory's Mutagen sync sessions locally, then reaches the
|
|
230
|
+
* remote to strip the MCP entries the matching flags imply and, with
|
|
231
|
+
* --delete-remote-dir, remove the synced dir. devtools/rpc are read for
|
|
232
|
+
* presence only — their port values are irrelevant to removal.
|
|
233
|
+
*/
|
|
234
|
+
async runCleanup(target, flags) {
|
|
235
|
+
if (!hasSshClient()) {
|
|
236
|
+
this.error('No `ssh` client found on PATH. Install OpenSSH client and try again.', { code: '1' });
|
|
237
|
+
}
|
|
238
|
+
const removeDevtoolsMcp = flags.devtools !== undefined;
|
|
239
|
+
const removeToolsMcp = (flags['with-tool']?.length ?? 0) > 0;
|
|
240
|
+
const deleteRemoteDir = flags['delete-remote-dir'];
|
|
241
|
+
const { remoteDir } = buildContext({ cwd: process.cwd(), remoteBase: flags['remote-base'] });
|
|
242
|
+
const slug = slugify(basename(remoteDir));
|
|
243
|
+
this.log(chalk.bold('Cleaning up workspace'));
|
|
244
|
+
this.log(` ${chalk.dim('remote:')} ${target}`);
|
|
245
|
+
this.log(` ${chalk.dim('remote dir:')} ${remoteDir}`);
|
|
246
|
+
this.log('');
|
|
247
|
+
// Terminate any lingering sync sessions for this directory (a local Mutagen op).
|
|
248
|
+
if (hasMutagen()) {
|
|
249
|
+
this.log(chalk.dim('Terminating any leftover Mutagen sync sessions…'));
|
|
250
|
+
await runMutagen(buildMutagenTerminateSelectorArgs(slug)).catch(() => 1);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
this.log(chalk.yellow('Mutagen CLI not found on PATH — skipping sync termination.'));
|
|
254
|
+
}
|
|
255
|
+
if (removeDevtoolsMcp) {
|
|
256
|
+
this.log(chalk.dim('Removing any leftover chrome-devtools MCP config on the remote…'));
|
|
257
|
+
}
|
|
258
|
+
if (removeToolsMcp) {
|
|
259
|
+
this.log(chalk.dim('Removing any leftover fnd-tools MCP config on the remote…'));
|
|
260
|
+
}
|
|
261
|
+
if (deleteRemoteDir) {
|
|
262
|
+
this.log(chalk.dim('Deleting the remote workspace directory…'));
|
|
263
|
+
}
|
|
264
|
+
const code = await runRemoteCleanup(target, remoteDir, { deleteRemoteDir, removeDevtoolsMcp, removeToolsMcp });
|
|
265
|
+
if (code === 0) {
|
|
266
|
+
this.log(chalk.green('✓ Done.'));
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
this.error(`Remote cleanup ssh session exited with code ${code}.`, { code: '1' });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
183
272
|
/** Run the interactive ssh session, inheriting the TTY so the remote shell is fully interactive. */
|
|
184
273
|
runSsh(target, script, ctx) {
|
|
185
274
|
// Reverse tunnels: remote 127.0.0.1:<remote> → this machine's 127.0.0.1:<local>.
|
|
186
|
-
// --devtools points one at the local browser's debug port; --
|
|
187
|
-
// the local
|
|
188
|
-
const forwards = [ctx.devtools, ctx.
|
|
275
|
+
// --devtools points one at the local browser's debug port; --with-tool points
|
|
276
|
+
// one at the local tools MCP server, so the remote's MCPs can reach them.
|
|
277
|
+
const forwards = [ctx.devtools, ctx.tools?.port].filter((f) => f !== undefined);
|
|
189
278
|
const args = [
|
|
190
279
|
'-t', // allocate a remote PTY for the interactive shell session
|
|
191
280
|
...(forwards.length > 0 ? ['-o', 'ExitOnForwardFailure=yes'] : []),
|
|
@@ -199,4 +288,30 @@ export default class Workspace extends Command {
|
|
|
199
288
|
child.once('close', (code) => resolve(code ?? 0));
|
|
200
289
|
});
|
|
201
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* Best-effort: reach back to the remote after the session to strip any MCP
|
|
293
|
+
* config this run registered and, with --delete-remote-dir, remove the synced
|
|
294
|
+
* dir. No-op when the session left nothing behind to clean. A failure here is
|
|
295
|
+
* logged, not thrown — the local sync is already torn down by this point.
|
|
296
|
+
*/
|
|
297
|
+
async teardownRemote(ctx, target, deleteRemoteDir) {
|
|
298
|
+
if (!ctx.devtools && !ctx.tools && !deleteRemoteDir)
|
|
299
|
+
return;
|
|
300
|
+
const actions = [
|
|
301
|
+
ctx.devtools ? 'removing the remote chrome-devtools MCP config' : undefined,
|
|
302
|
+
ctx.tools ? 'removing the remote fnd-tools MCP config' : undefined,
|
|
303
|
+
deleteRemoteDir ? 'deleting the remote dir' : undefined,
|
|
304
|
+
].filter(Boolean);
|
|
305
|
+
this.log(chalk.dim(`${actions.join(' and ')}…`.replace(/^./, (c) => c.toUpperCase())));
|
|
306
|
+
try {
|
|
307
|
+
await runRemoteCleanup(target, ctx.remoteDir, {
|
|
308
|
+
deleteRemoteDir,
|
|
309
|
+
removeDevtoolsMcp: Boolean(ctx.devtools),
|
|
310
|
+
removeToolsMcp: Boolean(ctx.tools),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
this.log(chalk.yellow(`Could not reach the remote to clean up (${error.message}).`));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
202
317
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alt-text generation: fetch an image, make it something a vision model can
|
|
3
|
+
* read, and ask a locally-hosted Ollama model to describe it.
|
|
4
|
+
*
|
|
5
|
+
* Everything runs against the user's own Ollama host, so no image ever leaves
|
|
6
|
+
* their network and there is no per-image API cost — which is also why the
|
|
7
|
+
* caller drives this strictly sequentially: a single local model gains nothing
|
|
8
|
+
* from concurrent requests.
|
|
9
|
+
*/
|
|
10
|
+
import type { ImageFilter, ImageMeta } from './image-filter.js';
|
|
11
|
+
export declare const DEFAULT_OLLAMA_HOST = "http://localhost:11434";
|
|
12
|
+
/**
|
|
13
|
+
* One image's worth of result. The caller already knows the URL, so this is
|
|
14
|
+
* everything else worth recording about the round trip.
|
|
15
|
+
*
|
|
16
|
+
* `bytes` is the size of what was actually sent to the model, i.e. after the
|
|
17
|
+
* PNG conversion below — not `meta.fileSize`, which is the (usually much
|
|
18
|
+
* smaller) WebP the site serves. `ms` is wall clock for the whole thing
|
|
19
|
+
* (download, convert, inference), because that is what a run's duration is
|
|
20
|
+
* actually made of.
|
|
21
|
+
*/
|
|
22
|
+
export interface Description {
|
|
23
|
+
alt: string;
|
|
24
|
+
bytes: number;
|
|
25
|
+
meta: ImageMeta;
|
|
26
|
+
ms: number;
|
|
27
|
+
skipped: false;
|
|
28
|
+
tokens: number;
|
|
29
|
+
}
|
|
30
|
+
/** An image the filter rejected. It was downloaded, but never sent to the model. */
|
|
31
|
+
export interface Skipped {
|
|
32
|
+
meta: ImageMeta;
|
|
33
|
+
skipped: true;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* `context`, when given, is free-form usage context for this specific image
|
|
37
|
+
* (e.g. the Shopify product it's attached to) that the caller has dug up from
|
|
38
|
+
* somewhere the pixels can't show. It's folded into the prompt as a hint.
|
|
39
|
+
*
|
|
40
|
+
* `onDownloaded` fires once the image is fetched and about to go to the model —
|
|
41
|
+
* the boundary between the two slow phases (download, then inference) — so a
|
|
42
|
+
* caller can update a spinner from "downloading" to "describing". It does NOT
|
|
43
|
+
* fire when the image is filtered out or on a dry run, since neither reaches the
|
|
44
|
+
* model.
|
|
45
|
+
*/
|
|
46
|
+
export type Describe = (url: string, context?: string, onDownloaded?: () => void) => Promise<Description | Skipped>;
|
|
47
|
+
/**
|
|
48
|
+
* The `type` a filter expression sees: a bare format token, not a MIME type.
|
|
49
|
+
*
|
|
50
|
+
* Content-Type is trusted first and the extension is the fallback, since CDNs
|
|
51
|
+
* serve plenty of images from extensionless URLs. `jpg` is normalized to `jpeg`
|
|
52
|
+
* and `svg+xml` to `svg` so an expression doesn't have to spell both.
|
|
53
|
+
*
|
|
54
|
+
* Exported so a caller that already knows an image's MIME type (e.g. Shopify's
|
|
55
|
+
* GraphQL `mimeType`) can produce the same `type` token a downloaded image would
|
|
56
|
+
* get, without re-fetching the bytes.
|
|
57
|
+
*/
|
|
58
|
+
export declare const detectType: (contentType: string, path: string) => string;
|
|
59
|
+
/**
|
|
60
|
+
* Every model pulled on the host that can actually read an image.
|
|
61
|
+
*
|
|
62
|
+
* /api/tags (`list`) is the only endpoint that enumerates models, but it says
|
|
63
|
+
* nothing about what a model can do — the capability list lives on /api/show
|
|
64
|
+
* (`show`), so each tag is asked individually. The calls are local and run
|
|
65
|
+
* concurrently; a model whose show() fails (pulled but broken, or removed
|
|
66
|
+
* between the two calls) is simply left out rather than failing the run.
|
|
67
|
+
*/
|
|
68
|
+
export declare const listVisionModels: (host: string) => Promise<string[]>;
|
|
69
|
+
/**
|
|
70
|
+
* Bind a describer to one Ollama host and model. The client is created once and
|
|
71
|
+
* reused so the connection (and the loaded model, via keep_alive) survives
|
|
72
|
+
* across images.
|
|
73
|
+
*
|
|
74
|
+
* `filter`, when given, decides whether an image is worth describing — it runs
|
|
75
|
+
* after the download (see fetchImage) but before inference, and a rejected
|
|
76
|
+
* image comes back as `{skipped: true}` rather than throwing, because being
|
|
77
|
+
* filtered out is a normal outcome and not a failure.
|
|
78
|
+
*
|
|
79
|
+
* `dry` short-circuits right after the download: the image is fetched and
|
|
80
|
+
* measured (so the caller can filter on real metadata for images whose size or
|
|
81
|
+
* dimensions weren't known up front) but nothing is sent to the model, and the
|
|
82
|
+
* filter is NOT applied here — a dry run's filtering is the command's job, so it
|
|
83
|
+
* can decide uniformly whether the meta came from this download or from an API
|
|
84
|
+
* that already knew it. The returned Description carries real `meta`/`bytes` and
|
|
85
|
+
* an empty `alt` the caller never reads.
|
|
86
|
+
*/
|
|
87
|
+
export declare const createDescriber: (host: string, model: string, filter?: ImageFilter, dry?: boolean) => Describe;
|