@firenet-designs/fnd-cli 2.0.0 → 2.2.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 CHANGED
@@ -17,29 +17,60 @@ A new CLI generated with oclif
17
17
  <!-- usage -->
18
18
  ```sh-session
19
19
  $ npm install -g @firenet-designs/fnd-cli
20
- $ fnd-cli COMMAND
20
+ $ fnd COMMAND
21
21
  running command...
22
- $ fnd-cli (--version)
23
- @firenet-designs/fnd-cli/2.0.0 linux-x64 node-v24.12.0
24
- $ fnd-cli --help [COMMAND]
22
+ $ fnd (--version)
23
+ @firenet-designs/fnd-cli/2.2.0 linux-x64 node-v24.18.0
24
+ $ fnd --help [COMMAND]
25
25
  USAGE
26
- $ fnd-cli COMMAND
26
+ $ fnd COMMAND
27
27
  ...
28
28
  ```
29
29
  <!-- usagestop -->
30
30
  # Commands
31
31
  <!-- commands -->
32
- * [`fnd-cli create-app`](#fnd-cli-create-app)
33
- * [`fnd-cli help [COMMAND]`](#fnd-cli-help-command)
34
- * [`fnd-cli token`](#fnd-cli-token)
32
+ * [`fnd backfill-project [SHOP]`](#fnd-backfill-project-shop)
33
+ * [`fnd create-app`](#fnd-create-app)
34
+ * [`fnd create-project [SHOP]`](#fnd-create-project-shop)
35
+ * [`fnd help [COMMAND]`](#fnd-help-command)
36
+ * [`fnd token`](#fnd-token)
37
+ * [`fnd workspace`](#fnd-workspace)
38
+ * [`fnd workspace cleanup`](#fnd-workspace-cleanup)
35
39
 
36
- ## `fnd-cli create-app`
40
+ ## `fnd backfill-project [SHOP]`
41
+
42
+ Backfill missing scaffold files in an EXISTING repo + theme: .gitignore, .shopifyignore, CLAUDE.md. Each is created only if absent — existing files are left untouched. Does NOT git init, pull the theme, commit, or touch GitHub.
43
+
44
+ ```
45
+ USAGE
46
+ $ fnd backfill-project [SHOP...]
47
+
48
+ ARGUMENTS
49
+ [SHOP...] Shopify store handle, passed to Claude as a hint
50
+
51
+ DESCRIPTION
52
+ Backfill missing scaffold files in an EXISTING repo + theme: .gitignore, .shopifyignore, CLAUDE.md. Each is created
53
+ only if absent — existing files are left untouched. Does NOT git init, pull the theme, commit, or touch GitHub.
54
+
55
+ Requires the claude CLI (npm install -g @anthropic-ai/claude-code) for the CLAUDE.md step.
56
+
57
+ EXAMPLES
58
+ $ fnd backfill-project
59
+
60
+ $ fnd backfill-project my-store
61
+
62
+ $ fnd backfill-project my-store "extra hints"
63
+ ```
64
+
65
+ _See code: [src/commands/backfill-project.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.2.0/src/commands/backfill-project.ts)_
66
+
67
+ ## `fnd create-app`
37
68
 
38
69
  sets up a preconfigued vite app directory on for a shopify theme
39
70
 
40
71
  ```
41
72
  USAGE
42
- $ fnd-cli create-app [-d <value>] [-s]
73
+ $ fnd create-app [-d <value>] [-s]
43
74
 
44
75
  FLAGS
45
76
  -d, --dir=<value> [default: src] directory to create the vite app in
@@ -49,18 +80,48 @@ DESCRIPTION
49
80
  sets up a preconfigued vite app directory on for a shopify theme
50
81
 
51
82
  EXAMPLES
52
- $ fnd-cli create-app
83
+ $ fnd create-app
84
+ ```
85
+
86
+ _See code: [src/commands/create-app.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.2.0/src/commands/create-app.ts)_
87
+
88
+ ## `fnd create-project [SHOP]`
89
+
90
+ 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.
91
+
92
+ ```
93
+ USAGE
94
+ $ fnd create-project [SHOP...]
95
+
96
+ ARGUMENTS
97
+ [SHOP...] Shopify store handle — pulls the live theme from <shop>.myshopify.com (omit to skip)
98
+
99
+ DESCRIPTION
100
+ Scaffold a new client project: git on branch production, ignore files, Shopify theme pull, a Claude-generated
101
+ CLAUDE.md, then a private GitHub repo under the FireNet-Designs org.
102
+
103
+ Requires the claude CLI (npm install -g @anthropic-ai/claude-code) for the CLAUDE.md step.
104
+
105
+ GitHub auth comes from YOUR environment — run `gh auth login` once, or export GH_TOKEN in your shell profile. This CLI
106
+ never stores credentials. Override the org with FND_GH_ORG.
107
+
108
+ EXAMPLES
109
+ $ fnd create-project
110
+
111
+ $ fnd create-project my-store
112
+
113
+ $ fnd create-project my-store "Acme storefront, Klaviyo"
53
114
  ```
54
115
 
55
- _See code: [src/commands/create-app.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.0.0/src/commands/create-app.ts)_
116
+ _See code: [src/commands/create-project.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.2.0/src/commands/create-project.ts)_
56
117
 
57
- ## `fnd-cli help [COMMAND]`
118
+ ## `fnd help [COMMAND]`
58
119
 
59
- Display help for fnd-cli.
120
+ Display help for fnd.
60
121
 
61
122
  ```
62
123
  USAGE
63
- $ fnd-cli help [COMMAND...] [-n]
124
+ $ fnd help [COMMAND...] [-n]
64
125
 
65
126
  ARGUMENTS
66
127
  [COMMAND...] Command to show help for.
@@ -69,18 +130,18 @@ FLAGS
69
130
  -n, --nested-commands Include all nested commands in the output.
70
131
 
71
132
  DESCRIPTION
72
- Display help for fnd-cli.
133
+ Display help for fnd.
73
134
  ```
74
135
 
75
- _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.2.37/src/commands/help.ts)_
136
+ _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/6.2.53/src/commands/help.ts)_
76
137
 
77
- ## `fnd-cli token`
138
+ ## `fnd token`
78
139
 
79
140
  Mint an offline Shopify Admin API access token (shpat_) for a custom app via the OAuth authorization-code grant. Works cross-org, unlike client_credentials.
80
141
 
81
142
  ```
82
143
  USAGE
83
- $ fnd-cli token [-i <value>] [-c <value>] [--no-open] [-p <value>] [--scopes <value>] [-s <value>] [-w
144
+ $ fnd token [-i <value>] [-c <value>] [--no-open] [-p <value>] [--scopes <value>] [-s <value>] [-w
84
145
  <value>]
85
146
 
86
147
  FLAGS
@@ -97,12 +158,74 @@ DESCRIPTION
97
158
  cross-org, unlike client_credentials.
98
159
 
99
160
  EXAMPLES
100
- $ fnd-cli token --store trafalgar-store --client-id 0b42... --client-secret shpss_...
161
+ $ fnd token --store trafalgar-store --client-id 0b42... --client-secret shpss_...
162
+
163
+ $ fnd token -s mystore -i <id> -c <secret> --scopes read_products,read_orders
164
+
165
+ $ fnd token -s mystore -i <id> -c <secret> --write ./.mcp.json
166
+ ```
167
+
168
+ _See code: [src/commands/token.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.2.0/src/commands/token.ts)_
169
+
170
+ ## `fnd workspace`
171
+
172
+ Open a remote workspace: mirror the current directory onto a remote Linux box via reverse SSHFS and drop into a shell there, cleaning everything up on exit.
173
+
174
+ ```
175
+ USAGE
176
+ $ fnd workspace --ssh <value> [--local-ssh-port <value>] [--mount-base <value>] [-p <value>]
177
+
178
+ FLAGS
179
+ -p, --port=<value> reverse-tunnel port opened on the remote (random 20000-60000 if omitted)
180
+ --local-ssh-port=<value> [default: 22] port your LOCAL SSH server listens on (the tunnel forwards back to this)
181
+ --mount-base=<value> [default: /home/fnd] base dir on the remote; the mount lands at
182
+ <base>/<local-user>/<dir-name>
183
+ --ssh=<value> (required) remote to connect to, as user@host
184
+
185
+ DESCRIPTION
186
+ Open a remote workspace: mirror the current directory onto a remote Linux box via reverse SSHFS and drop into a shell
187
+ there, cleaning everything up on exit.
188
+
189
+ The remote reaches your machine through the ssh tunnel (-R), so your local SSH server is never exposed to the internet
190
+ or port-forwarded. For the back-connection the remote authenticates with its OWN SSH key, which is temporarily added
191
+ to your local authorized_keys and removed again on exit. Locally you need an SSH *server* running (sshd), not sshfs —
192
+ sshfs runs on the remote.
193
+
194
+ EXAMPLES
195
+ $ fnd workspace --ssh user@203.0.113.4
196
+
197
+ $ fnd workspace --ssh user@host --port 40222
198
+
199
+ $ fnd workspace --ssh user@host --mount-base /home/fnd --local-ssh-port 2222
200
+ ```
201
+
202
+ _See code: [src/commands/workspace/index.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.2.0/src/commands/workspace/index.ts)_
203
+
204
+ ## `fnd workspace cleanup`
205
+
206
+ Unmount a leftover reverse-SSHFS mount on the remote — use this if a workspace session dropped before it could clean up after itself.
207
+
208
+ ```
209
+ USAGE
210
+ $ fnd workspace cleanup --ssh <value> [--mount <value>] [--mount-base <value>]
211
+
212
+ FLAGS
213
+ --mount=<value> exact remote mount path to unmount (defaults to the current dir mapping)
214
+ --mount-base=<value> [default: /home/fnd] base dir on the remote, used to derive the default mount path
215
+ --ssh=<value> (required) remote to connect to, as user@host
101
216
 
102
- $ fnd-cli token -s mystore -i <id> -c <secret> --scopes read_products,read_orders
217
+ DESCRIPTION
218
+ Unmount a leftover reverse-SSHFS mount on the remote — use this if a workspace session dropped before it could clean
219
+ up after itself.
220
+
221
+ Unmounts the remote mount and removes the matching entry from your local authorized_keys. With no --mount, it targets
222
+ the same path `workspace` would use for the current directory.
223
+
224
+ EXAMPLES
225
+ $ fnd workspace cleanup --ssh user@host
103
226
 
104
- $ fnd-cli token -s mystore -i <id> -c <secret> --write ./.mcp.json
227
+ $ fnd workspace cleanup --ssh user@host --mount /home/fnd/cole/fnd-cli
105
228
  ```
106
229
 
107
- _See code: [src/commands/token.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.0.0/src/commands/token.ts)_
230
+ _See code: [src/commands/workspace/cleanup.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.2.0/src/commands/workspace/cleanup.ts)_
108
231
  <!-- commandsstop -->
@@ -0,0 +1,10 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class BackfillProject extends Command {
3
+ static args: {
4
+ shop: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static strict: boolean;
9
+ run(): Promise<void>;
10
+ }
@@ -0,0 +1,61 @@
1
+ import { Args, Command } from '@oclif/core';
2
+ import chalk from 'chalk';
3
+ import { existsSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { generateClaudeMd, GITIGNORE_CONTENT, initPromptPath, SHOPIFYIGNORE_CONTENT, } from '../lib/scaffold.js';
6
+ export default class BackfillProject extends Command {
7
+ static args = {
8
+ shop: Args.string({ description: 'Shopify store handle, passed to Claude as a hint', required: false }),
9
+ };
10
+ static description = 'Backfill missing scaffold files in an EXISTING repo + theme: .gitignore, .shopifyignore, CLAUDE.md. Each is created only if absent — existing files are left untouched. Does NOT git init, pull the theme, commit, or touch GitHub.\n\nRequires the claude CLI (npm install -g @anthropic-ai/claude-code) for the CLAUDE.md step.';
11
+ static examples = [
12
+ '<%= config.bin %> <%= command.id %>',
13
+ '<%= config.bin %> <%= command.id %> my-store',
14
+ '<%= config.bin %> <%= command.id %> my-store "extra hints"',
15
+ ];
16
+ // Everything after the shop handle is collected as free-form hints for Claude.
17
+ static strict = false;
18
+ async run() {
19
+ const { argv } = await this.parse(BackfillProject);
20
+ const [shop, ...rest] = argv;
21
+ const hints = rest.join(' ');
22
+ if (existsSync('.gitignore')) {
23
+ this.log(chalk.dim('✓ .gitignore exists — skipping'));
24
+ }
25
+ else {
26
+ writeFileSync('.gitignore', GITIGNORE_CONTENT);
27
+ this.log(chalk.green('✅ .gitignore created'));
28
+ }
29
+ if (existsSync('.shopifyignore')) {
30
+ this.log(chalk.dim('✓ .shopifyignore exists — skipping'));
31
+ }
32
+ else {
33
+ writeFileSync('.shopifyignore', SHOPIFYIGNORE_CONTENT);
34
+ this.log(chalk.green('✅ .shopifyignore created'));
35
+ }
36
+ if (existsSync(join(process.cwd(), 'CLAUDE.md'))) {
37
+ this.log(chalk.dim('✓ CLAUDE.md exists — skipping'));
38
+ return;
39
+ }
40
+ this.log(chalk.blue('🤖 Generating CLAUDE.md with Claude…'));
41
+ const result = await generateClaudeMd(shop, hints);
42
+ switch (result) {
43
+ case 'claude-not-installed': {
44
+ this.error('claude CLI not found — install it with: npm install -g @anthropic-ai/claude-code', { code: '1' });
45
+ break;
46
+ }
47
+ case 'missing-prompt': {
48
+ this.error(`bundled prompt missing at ${initPromptPath()} — try reinstalling @firenet-designs/fnd-cli`, { code: '1' });
49
+ break;
50
+ }
51
+ case 'not-written': {
52
+ this.warn('Claude finished but no CLAUDE.md was found — check the output above');
53
+ break;
54
+ }
55
+ case 'written': {
56
+ this.log(chalk.green(`✅ CLAUDE.md written to ${process.cwd()}`));
57
+ break;
58
+ }
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,10 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class CreateProject extends Command {
3
+ static args: {
4
+ shop: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
+ };
6
+ static description: string;
7
+ static examples: string[];
8
+ static strict: boolean;
9
+ run(): Promise<void>;
10
+ }
@@ -0,0 +1,122 @@
1
+ import { Args, Command } from '@oclif/core';
2
+ import chalk from 'chalk';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { existsSync, writeFileSync } from 'node:fs';
5
+ import { basename, dirname, join } from 'node:path';
6
+ import { simpleGit } from 'simple-git';
7
+ import { findShopifyBin, generateClaudeMd, GITIGNORE_CONTENT, initPromptPath, run, SHOPIFYIGNORE_CONTENT, which, } from '../lib/scaffold.js';
8
+ export default class CreateProject extends Command {
9
+ static args = {
10
+ shop: Args.string({ description: 'Shopify store handle — pulls the live theme from <shop>.myshopify.com (omit to skip)', required: false }),
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.';
13
+ static examples = [
14
+ '<%= config.bin %> <%= command.id %>',
15
+ '<%= config.bin %> <%= command.id %> my-store',
16
+ '<%= config.bin %> <%= command.id %> my-store "Acme storefront, Klaviyo"',
17
+ ];
18
+ // Everything after the shop handle is collected as free-form hints for Claude.
19
+ static strict = false;
20
+ async run() {
21
+ const { argv } = await this.parse(CreateProject);
22
+ const [shop, ...rest] = argv;
23
+ const hints = rest.join(' ');
24
+ const git = simpleGit();
25
+ // 1. git init on branch 'production'
26
+ if (existsSync(join(process.cwd(), '.git'))) {
27
+ this.log(chalk.blue('ℹ️ git repo already initialized — leaving the current branch as-is'));
28
+ }
29
+ else {
30
+ await git.raw(['init', '-b', 'production']);
31
+ this.log(chalk.green("✅ git initialized on branch 'production'"));
32
+ }
33
+ // 2. ignore files (always written, matching the original scaffold)
34
+ writeFileSync('.gitignore', GITIGNORE_CONTENT);
35
+ writeFileSync('.shopifyignore', SHOPIFYIGNORE_CONTENT);
36
+ this.log(chalk.green('✅ .gitignore and .shopifyignore created'));
37
+ // 3. Pull the live Shopify theme so Claude can inspect real files when it
38
+ // writes CLAUDE.md. Only runs when a shop handle was passed.
39
+ if (shop) {
40
+ const shopifyBin = findShopifyBin();
41
+ if (shopifyBin) {
42
+ // Prepend the binary's own dir so its `env node` shebang resolves to
43
+ // the node it was installed with, not an older active version.
44
+ this.log(chalk.blue(`🎨 Pulling Shopify theme from store '${shop}' (via ${shopifyBin})…`));
45
+ const env = { ...process.env, PATH: `${dirname(shopifyBin)}:${process.env.PATH}` };
46
+ await run(shopifyBin, ['theme', 'pull', `--store=${shop}`], env);
47
+ }
48
+ else {
49
+ this.log(chalk.blue(`ℹ️ shopify CLI not found — run manually: shopify theme pull --store=${shop}`));
50
+ }
51
+ }
52
+ // 4. CLAUDE.md via Claude (skipped with a warning when claude isn't
53
+ // installed — the GitHub step below still runs)
54
+ this.log(chalk.blue('🤖 Generating CLAUDE.md with Claude…'));
55
+ const claudeResult = await generateClaudeMd(shop, hints);
56
+ switch (claudeResult) {
57
+ case 'claude-not-installed': {
58
+ this.warn('claude CLI not found — skipping CLAUDE.md. Install it with: npm install -g @anthropic-ai/claude-code');
59
+ break;
60
+ }
61
+ case 'missing-prompt': {
62
+ this.error(`bundled prompt missing at ${initPromptPath()} — try reinstalling @firenet-designs/fnd-cli`, { code: '1' });
63
+ break;
64
+ }
65
+ case 'not-written': {
66
+ this.warn('Claude finished but no CLAUDE.md was found — check the output above');
67
+ break;
68
+ }
69
+ case 'written': {
70
+ this.log(chalk.green(`✅ CLAUDE.md written to ${process.cwd()}`));
71
+ break;
72
+ }
73
+ }
74
+ // 5. Private GitHub repo. The create-only token (GH_TOKEN) is used by gh
75
+ // for the API call only; the push goes over SSH with the user's own key.
76
+ if (!which('gh')) {
77
+ this.log(chalk.blue('ℹ️ gh not installed — skipping GitHub. Install with: brew install gh'));
78
+ return;
79
+ }
80
+ const hasTokenInEnv = Boolean(process.env.GH_TOKEN || process.env.GITHUB_TOKEN);
81
+ const ghAuthenticated = spawnSync('gh', ['auth', 'status'], { stdio: 'ignore' }).status === 0;
82
+ if (!hasTokenInEnv && !ghAuthenticated) {
83
+ this.log(chalk.yellow('⚠️ No GitHub auth found — skipping repo creation. Set it up once, then re-run:'));
84
+ this.log(chalk.yellow(' • easiest: gh auth login'));
85
+ this.log(chalk.yellow(' • or add to your shell profile (~/.zshrc): export GH_TOKEN=<fine-grained PAT>'));
86
+ this.log(chalk.yellow(' (PAT only needs repo-create on the FireNet-Designs org — push goes over your SSH key)'));
87
+ return;
88
+ }
89
+ const remotes = await git.getRemotes();
90
+ if (remotes.some((r) => r.name === 'origin')) {
91
+ this.log(chalk.blue("ℹ️ remote 'origin' already set — skipping GitHub repo creation"));
92
+ return;
93
+ }
94
+ const org = process.env.FND_GH_ORG || 'FireNet-Designs';
95
+ const repo = `${org}/${basename(process.cwd())}`;
96
+ this.log(chalk.blue('📦 Committing initial scaffold…'));
97
+ await git.raw(['add', '-A']);
98
+ try {
99
+ await git.raw(['commit', '-q', '-m', 'initial commit']);
100
+ }
101
+ catch {
102
+ this.error('nothing to commit — aborting GitHub step', { code: '1' });
103
+ }
104
+ this.log(chalk.blue(`🐙 Creating private GitHub repo '${repo}'…`));
105
+ const createCode = await run('gh', ['repo', 'create', repo, '--private', '--source=.', '--remote=origin']);
106
+ if (createCode !== 0) {
107
+ this.error('gh repo create failed — see output above', { code: '1' });
108
+ }
109
+ // Force SSH so the push authenticates with the user's key, not the
110
+ // create-only token.
111
+ await git.raw(['remote', 'set-url', 'origin', `git@github.com:${repo}.git`]);
112
+ this.log(chalk.blue("⬆️ Pushing 'production' to origin over SSH…"));
113
+ const pushCode = await run('git', ['push', '-u', 'origin', 'production']);
114
+ if (pushCode === 0) {
115
+ spawnSync('gh', ['repo', 'edit', repo, '--default-branch', 'production'], { stdio: 'ignore' });
116
+ this.log(chalk.green(`✅ Created + pushed. origin = git@github.com:${repo}.git`));
117
+ }
118
+ else {
119
+ this.warn(`push failed — check your SSH access to the ${org} org`);
120
+ }
121
+ }
122
+ }
@@ -13,6 +13,8 @@ export default class Token extends Command {
13
13
  };
14
14
  run(): Promise<void>;
15
15
  private mcpSnippet;
16
+ /** Loud, boxed reminder of the one-time Dev Dashboard setup. */
17
+ private printSetupBanner;
16
18
  /** Minimal readline prompt — keeps the CLI dependency-free. */
17
19
  private prompt;
18
20
  private runOAuthFlow;
@@ -50,11 +50,7 @@ export default class Token extends Command {
50
50
  const { scopes } = flags;
51
51
  const { port } = flags;
52
52
  const redirectUri = `http://localhost:${port}/callback`;
53
- this.log('');
54
- this.log(chalk.dim('Before continuing, the app must list this exact redirect URL:'));
55
- this.log(' ' + chalk.cyan(redirectUri));
56
- this.log(chalk.dim('and have these scopes released:') + ' ' + chalk.cyan(scopes));
57
- this.log('');
53
+ this.printSetupBanner(redirectUri, scopes);
58
54
  const token = await this.runOAuthFlow({ clientId, clientSecret, open: !flags['no-open'], port, redirectUri, scopes, store });
59
55
  this.log('');
60
56
  this.log(chalk.green('✓ Offline access token (never expires until uninstall/secret rotation):'));
@@ -79,6 +75,37 @@ export default class Token extends Command {
79
75
  };
80
76
  return chalk.dim(JSON.stringify(config, null, 2));
81
77
  }
78
+ /** Loud, boxed reminder of the one-time Dev Dashboard setup. */
79
+ printSetupBanner(redirectUri, scopes) {
80
+ const rows = [
81
+ { kind: 'header', text: '⚠ ONE-TIME APP SETUP — in the Shopify Dev Dashboard' },
82
+ { kind: 'body', text: '' },
83
+ { kind: 'body', text: 'The custom app for this store MUST have BOTH of these,' },
84
+ { kind: 'body', text: 'or Shopify shows an error instead of an approve screen:' },
85
+ { kind: 'body', text: '' },
86
+ { kind: 'body', text: '1) Add this Redirect URL to the app (exactly):' },
87
+ { kind: 'value', text: ` ${redirectUri}` },
88
+ { kind: 'body', text: '2) Release a version with this scope:' },
89
+ { kind: 'value', text: ` ${scopes}` },
90
+ ];
91
+ const width = Math.max(...rows.map((r) => r.text.length));
92
+ const bar = '─'.repeat(width + 2);
93
+ this.log('');
94
+ this.log(chalk.yellow.bold(`┌${bar}┐`));
95
+ for (const row of rows) {
96
+ const padded = ` ${row.text.padEnd(width)} `;
97
+ let body;
98
+ if (row.kind === 'header')
99
+ body = chalk.bgYellow.black.bold(padded);
100
+ else if (row.kind === 'value')
101
+ body = chalk.cyanBright.bold(padded);
102
+ else
103
+ body = chalk.yellow(padded);
104
+ this.log(chalk.yellow.bold('│') + body + chalk.yellow.bold('│'));
105
+ }
106
+ this.log(chalk.yellow.bold(`└${bar}┘`));
107
+ this.log('');
108
+ }
82
109
  /** Minimal readline prompt — keeps the CLI dependency-free. */
83
110
  async prompt(label) {
84
111
  const rl = createInterface({ input: stdin, output: stdout });
@@ -110,16 +137,30 @@ export default class Token extends Command {
110
137
  }
111
138
  const code = url.searchParams.get('code');
112
139
  const returnedState = url.searchParams.get('state');
140
+ const oauthError = url.searchParams.get('error');
113
141
  const fail = (message, httpBody) => {
114
142
  res.writeHead(400, { 'Content-Type': 'text/html' }).end(httpBody);
115
143
  spinner.fail(message);
116
144
  server.close();
117
145
  reject(new Error(message));
118
146
  };
119
- if (returnedState !== state)
120
- return fail('State mismatch (possible CSRF) aborting.', '<h2>State mismatch. Restart the flow.</h2>');
147
+ // Ignore spurious/stale hits — a bare probe, or a leftover browser tab
148
+ // from a previous run replaying an old callback on this same port.
149
+ // These must NOT abort the flow; only a callback carrying OUR exact
150
+ // state is allowed to proceed.
151
+ if (!code && !oauthError) {
152
+ res.writeHead(204).end();
153
+ return;
154
+ }
155
+ if (returnedState !== state) {
156
+ res.writeHead(409, { 'Content-Type': 'text/html' }).end('<h2>Stale authorization tab — ignore this and use the newest link from your terminal.</h2>');
157
+ spinner.text = 'Ignored a stale/mismatched callback (likely an old browser tab). Still waiting …';
158
+ return;
159
+ }
160
+ if (oauthError)
161
+ return fail(`Authorization denied: ${oauthError}.`, '<h2>Authorization denied.</h2>');
121
162
  if (!code)
122
- return fail(`Authorization denied: ${url.searchParams.get('error') ?? 'no code returned'}.`, '<h2>No authorization code returned.</h2>');
163
+ return fail('No authorization code returned.', '<h2>No authorization code returned.</h2>');
123
164
  try {
124
165
  const tokenRes = await fetch(`https://${store}/admin/oauth/access_token`, {
125
166
  // Shopify's token endpoint requires these exact snake_case keys.
@@ -141,9 +182,18 @@ export default class Token extends Command {
141
182
  fail(`Error during token exchange: ${error.message}`, '<h2>Error. Check the terminal.</h2>');
142
183
  }
143
184
  });
185
+ // Don't wait forever; clear on close so any exit path stops the timer.
186
+ const timer = setTimeout(() => {
187
+ spinner.fail('Timed out after 5 minutes waiting for authorization.');
188
+ server.close();
189
+ reject(new Error('Timed out waiting for authorization.'));
190
+ }, 300_000);
191
+ timer.unref();
192
+ server.on('close', () => clearTimeout(timer));
144
193
  server.on('error', (error) => {
194
+ clearTimeout(timer);
145
195
  const message = error.code === 'EADDRINUSE'
146
- ? `Port ${port} is already in use. Pass a different --port (and update the app redirect URL).`
196
+ ? `Port ${port} is already in use — a previous run may still hold it. Kill it, or pass a different --port (and add that redirect URL to the app).`
147
197
  : error.message;
148
198
  reject(new Error(message));
149
199
  });
@@ -0,0 +1,11 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class WorkspaceCleanup extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ mount: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
+ 'mount-base': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
+ ssh: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
+ };
10
+ run(): Promise<void>;
11
+ }
@@ -0,0 +1,46 @@
1
+ import { Command, Flags } from '@oclif/core';
2
+ import chalk from 'chalk';
3
+ import { buildContext, DEFAULT_MOUNT_BASE, hasSshClient, keyCommentForMount, parseSshTarget, removeAuthorizedKey, runRemoteCleanup, } from '../../lib/workspace.js';
4
+ export default class WorkspaceCleanup extends Command {
5
+ static description = 'Unmount a leftover reverse-SSHFS mount on the remote — use this if a workspace session dropped before it could clean up after itself.\n\nUnmounts the remote mount and removes the matching entry from your local authorized_keys. With no --mount, it targets the same path `workspace` would use for the current directory.';
6
+ static examples = [
7
+ '<%= config.bin %> <%= command.id %> --ssh user@host',
8
+ '<%= config.bin %> <%= command.id %> --ssh user@host --mount /home/fnd/cole/fnd-cli',
9
+ ];
10
+ static flags = {
11
+ mount: Flags.string({
12
+ description: 'exact remote mount path to unmount (defaults to the current dir mapping)',
13
+ }),
14
+ 'mount-base': Flags.string({
15
+ default: DEFAULT_MOUNT_BASE,
16
+ description: 'base dir on the remote, used to derive the default mount path',
17
+ }),
18
+ ssh: Flags.string({
19
+ description: 'remote to connect to, as user@host',
20
+ required: true,
21
+ }),
22
+ };
23
+ async run() {
24
+ const { flags } = await this.parse(WorkspaceCleanup);
25
+ if (!hasSshClient()) {
26
+ this.error('No `ssh` client found on PATH. Install OpenSSH client and try again.', { code: '1' });
27
+ }
28
+ const target = parseSshTarget(flags.ssh);
29
+ const mount = flags.mount ?? buildContext({ cwd: process.cwd(), mountBase: flags['mount-base'] }).mount;
30
+ this.log(chalk.bold('Cleaning up remote mount'));
31
+ this.log(` ${chalk.dim('remote:')} ${target.user}@${target.host}`);
32
+ this.log(` ${chalk.dim('mount:')} ${mount}`);
33
+ this.log('');
34
+ const code = await runRemoteCleanup(`${target.user}@${target.host}`, mount);
35
+ // Revoke local trust for this workspace's key regardless of the remote result.
36
+ removeAuthorizedKey(keyCommentForMount(mount));
37
+ if (code === 0) {
38
+ this.log(chalk.green('✓ Done. Local authorized_keys entry removed.'));
39
+ }
40
+ else {
41
+ this.error(`Cleanup ssh session exited with code ${code} (local authorized_keys entry still removed).`, {
42
+ code: '1',
43
+ });
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,17 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class Workspace extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ 'local-ssh-port': import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
7
+ 'mount-base': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
8
+ port: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
+ ssh: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
+ };
11
+ run(): Promise<void>;
12
+ /** Verify the local machine can actually host the tunnel before we connect. */
13
+ private preflight;
14
+ private printPlan;
15
+ /** Run the interactive ssh session, inheriting the TTY so the remote shell is fully interactive. */
16
+ private runSsh;
17
+ }