@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 +146 -23
- package/dist/commands/backfill-project.d.ts +10 -0
- package/dist/commands/backfill-project.js +61 -0
- package/dist/commands/create-project.d.ts +10 -0
- package/dist/commands/create-project.js +122 -0
- package/dist/commands/token.d.ts +2 -0
- package/dist/commands/token.js +59 -9
- package/dist/commands/workspace/cleanup.d.ts +11 -0
- package/dist/commands/workspace/cleanup.js +46 -0
- package/dist/commands/workspace/index.d.ts +17 -0
- package/dist/commands/workspace/index.js +107 -0
- package/dist/lib/scaffold.d.ts +35 -0
- package/dist/lib/scaffold.js +101 -0
- package/dist/lib/workspace.d.ts +89 -0
- package/dist/lib/workspace.js +214 -0
- package/oclif.manifest.json +171 -1
- package/package.json +14 -4
- package/prompts/init-project-auto.md +183 -0
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
|
|
20
|
+
$ fnd COMMAND
|
|
21
21
|
running command...
|
|
22
|
-
$ fnd
|
|
23
|
-
@firenet-designs/fnd-cli/2.
|
|
24
|
-
$ fnd
|
|
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
|
|
26
|
+
$ fnd COMMAND
|
|
27
27
|
...
|
|
28
28
|
```
|
|
29
29
|
<!-- usagestop -->
|
|
30
30
|
# Commands
|
|
31
31
|
<!-- commands -->
|
|
32
|
-
* [`fnd-
|
|
33
|
-
* [`fnd-
|
|
34
|
-
* [`fnd-
|
|
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-
|
|
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
|
|
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
|
|
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-
|
|
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
|
|
118
|
+
## `fnd help [COMMAND]`
|
|
58
119
|
|
|
59
|
-
Display help for fnd
|
|
120
|
+
Display help for fnd.
|
|
60
121
|
|
|
61
122
|
```
|
|
62
123
|
USAGE
|
|
63
|
-
$ fnd
|
|
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
|
|
133
|
+
Display help for fnd.
|
|
73
134
|
```
|
|
74
135
|
|
|
75
|
-
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/
|
|
136
|
+
_See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/6.2.53/src/commands/help.ts)_
|
|
76
137
|
|
|
77
|
-
## `fnd
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
227
|
+
$ fnd workspace cleanup --ssh user@host --mount /home/fnd/cole/fnd-cli
|
|
105
228
|
```
|
|
106
229
|
|
|
107
|
-
_See code: [src/commands/
|
|
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
|
+
}
|
package/dist/commands/token.d.ts
CHANGED
|
@@ -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;
|
package/dist/commands/token.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
120
|
-
|
|
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(
|
|
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.
|
|
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
|
+
}
|