@firenet-designs/fnd-cli 1.1.1 → 2.0.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
@@ -20,7 +20,7 @@ $ npm install -g @firenet-designs/fnd-cli
20
20
  $ fnd-cli COMMAND
21
21
  running command...
22
22
  $ fnd-cli (--version)
23
- @firenet-designs/fnd-cli/1.1.1 darwin-x64 node-v24.12.0
23
+ @firenet-designs/fnd-cli/2.0.0 linux-x64 node-v24.12.0
24
24
  $ fnd-cli --help [COMMAND]
25
25
  USAGE
26
26
  $ fnd-cli COMMAND
@@ -31,6 +31,7 @@ USAGE
31
31
  <!-- commands -->
32
32
  * [`fnd-cli create-app`](#fnd-cli-create-app)
33
33
  * [`fnd-cli help [COMMAND]`](#fnd-cli-help-command)
34
+ * [`fnd-cli token`](#fnd-cli-token)
34
35
 
35
36
  ## `fnd-cli create-app`
36
37
 
@@ -51,7 +52,7 @@ EXAMPLES
51
52
  $ fnd-cli create-app
52
53
  ```
53
54
 
54
- _See code: [src/commands/create-app.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v1.1.1/src/commands/create-app.ts)_
55
+ _See code: [src/commands/create-app.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.0.0/src/commands/create-app.ts)_
55
56
 
56
57
  ## `fnd-cli help [COMMAND]`
57
58
 
@@ -72,4 +73,36 @@ DESCRIPTION
72
73
  ```
73
74
 
74
75
  _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.2.37/src/commands/help.ts)_
76
+
77
+ ## `fnd-cli token`
78
+
79
+ 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
+
81
+ ```
82
+ USAGE
83
+ $ fnd-cli token [-i <value>] [-c <value>] [--no-open] [-p <value>] [--scopes <value>] [-s <value>] [-w
84
+ <value>]
85
+
86
+ FLAGS
87
+ -c, --client-secret=<value> app Client secret (shpss_...)
88
+ -i, --client-id=<value> app Client ID
89
+ -p, --port=<value> [default: 3456] local callback port (must match the app redirect URL)
90
+ -s, --store=<value> store domain (mystore or mystore.myshopify.com)
91
+ -w, --write=<value> path to an .mcp.json whose 'shopify' server should be rewritten to use the token
92
+ --no-open don't auto-open the authorize URL in a browser
93
+ --scopes=<value> [default: read_products] comma-separated Admin API scopes to request
94
+
95
+ DESCRIPTION
96
+ Mint an offline Shopify Admin API access token (shpat_) for a custom app via the OAuth authorization-code grant. Works
97
+ cross-org, unlike client_credentials.
98
+
99
+ EXAMPLES
100
+ $ fnd-cli token --store trafalgar-store --client-id 0b42... --client-secret shpss_...
101
+
102
+ $ fnd-cli token -s mystore -i <id> -c <secret> --scopes read_products,read_orders
103
+
104
+ $ fnd-cli token -s mystore -i <id> -c <secret> --write ./.mcp.json
105
+ ```
106
+
107
+ _See code: [src/commands/token.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.0.0/src/commands/token.ts)_
75
108
  <!-- commandsstop -->
@@ -0,0 +1,20 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class Token extends Command {
3
+ static description: string;
4
+ static examples: string[];
5
+ static flags: {
6
+ 'client-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
7
+ 'client-secret': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ 'no-open': import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
+ port: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
10
+ scopes: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
+ store: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ write: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ };
14
+ run(): Promise<void>;
15
+ private mcpSnippet;
16
+ /** Minimal readline prompt — keeps the CLI dependency-free. */
17
+ private prompt;
18
+ private runOAuthFlow;
19
+ private writeMcpConfig;
20
+ }
@@ -0,0 +1,174 @@
1
+ import { Command, Flags } from '@oclif/core';
2
+ import chalk from 'chalk';
3
+ import { exec } from 'node:child_process';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { readFileSync, writeFileSync } from 'node:fs';
6
+ import { createServer } from 'node:http';
7
+ import { platform, stdin, stdout } from 'node:process';
8
+ import { createInterface } from 'node:readline/promises';
9
+ import ora from 'ora';
10
+ /**
11
+ * The Shopify `client_credentials` grant only works for apps and stores that
12
+ * live in the SAME Dev Dashboard org. For a client's paid store in another org
13
+ * it returns `shop_not_permitted`. This command runs the interactive
14
+ * authorization-code grant instead, which works cross-org and mints a permanent
15
+ * OFFLINE access token usable as `--access-token` by Shopify Admin tooling.
16
+ */
17
+ const DEFAULT_PORT = 3456;
18
+ const DEFAULT_SCOPES = 'read_products';
19
+ /** `mystore` or `https://mystore.myshopify.com/` -> `mystore.myshopify.com`. */
20
+ const normalizeStore = (raw) => {
21
+ const host = raw.trim().replace(/^https?:\/\//, '').replace(/\/.*$/, '');
22
+ return host.endsWith('.myshopify.com') ? host : `${host}.myshopify.com`;
23
+ };
24
+ /** Open a URL in the user's default browser (best-effort, never throws). */
25
+ const openBrowser = (url) => {
26
+ const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'start ""' : 'xdg-open';
27
+ exec(`${cmd} "${url}"`, () => { });
28
+ };
29
+ export default class Token extends Command {
30
+ static description = '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.';
31
+ static examples = [
32
+ '<%= config.bin %> <%= command.id %> --store trafalgar-store --client-id 0b42... --client-secret shpss_...',
33
+ '<%= config.bin %> <%= command.id %> -s mystore -i <id> -c <secret> --scopes read_products,read_orders',
34
+ '<%= config.bin %> <%= command.id %> -s mystore -i <id> -c <secret> --write ./.mcp.json',
35
+ ];
36
+ static flags = {
37
+ 'client-id': Flags.string({ char: 'i', description: 'app Client ID' }),
38
+ 'client-secret': Flags.string({ char: 'c', description: 'app Client secret (shpss_...)' }),
39
+ 'no-open': Flags.boolean({ description: "don't auto-open the authorize URL in a browser" }),
40
+ port: Flags.integer({ char: 'p', default: DEFAULT_PORT, description: 'local callback port (must match the app redirect URL)' }),
41
+ scopes: Flags.string({ default: DEFAULT_SCOPES, description: 'comma-separated Admin API scopes to request' }),
42
+ store: Flags.string({ char: 's', description: 'store domain (mystore or mystore.myshopify.com)' }),
43
+ write: Flags.string({ char: 'w', description: "path to an .mcp.json whose 'shopify' server should be rewritten to use the token" }),
44
+ };
45
+ async run() {
46
+ const { flags } = await this.parse(Token);
47
+ const store = normalizeStore(flags.store ?? (await this.prompt('Store domain (e.g. mystore)')));
48
+ const clientId = flags['client-id'] ?? (await this.prompt('Client ID'));
49
+ const clientSecret = flags['client-secret'] ?? (await this.prompt('Client secret (shpss_...)'));
50
+ const { scopes } = flags;
51
+ const { port } = flags;
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('');
58
+ const token = await this.runOAuthFlow({ clientId, clientSecret, open: !flags['no-open'], port, redirectUri, scopes, store });
59
+ this.log('');
60
+ this.log(chalk.green('✓ Offline access token (never expires until uninstall/secret rotation):'));
61
+ this.log(' ' + chalk.bold(token));
62
+ this.log('');
63
+ this.log(chalk.dim('Drop-in .mcp.json server config:'));
64
+ this.log(this.mcpSnippet(store, token));
65
+ if (flags.write) {
66
+ this.writeMcpConfig(flags.write, store, token);
67
+ this.log('');
68
+ this.log(chalk.green(`✓ Updated ${flags.write} (shopify server -> --access-token).`));
69
+ }
70
+ }
71
+ mcpSnippet(store, token) {
72
+ const config = {
73
+ mcpServers: {
74
+ shopify: {
75
+ args: ['-y', 'shopify-graphql-admin-mcp', '--store', store, '--access-token', token],
76
+ command: 'npx',
77
+ },
78
+ },
79
+ };
80
+ return chalk.dim(JSON.stringify(config, null, 2));
81
+ }
82
+ /** Minimal readline prompt — keeps the CLI dependency-free. */
83
+ async prompt(label) {
84
+ const rl = createInterface({ input: stdin, output: stdout });
85
+ try {
86
+ const answer = (await rl.question(`${label}: `)).trim();
87
+ if (!answer)
88
+ this.error(`${label} is required`, { code: '1' });
89
+ return answer;
90
+ }
91
+ finally {
92
+ rl.close();
93
+ }
94
+ }
95
+ runOAuthFlow(opts) {
96
+ const { clientId, clientSecret, open, port, redirectUri, scopes, store } = opts;
97
+ const state = randomBytes(16).toString('hex');
98
+ const authorizeUrl = `https://${store}/admin/oauth/authorize` +
99
+ `?client_id=${encodeURIComponent(clientId)}` +
100
+ `&scope=${encodeURIComponent(scopes)}` +
101
+ `&redirect_uri=${encodeURIComponent(redirectUri)}` +
102
+ `&state=${state}`;
103
+ const spinner = ora(`Waiting for authorization at ${redirectUri} …`);
104
+ return new Promise((resolve, reject) => {
105
+ const server = createServer(async (req, res) => {
106
+ const url = new URL(req.url ?? '/', `http://localhost:${port}`);
107
+ if (url.pathname !== '/callback') {
108
+ res.writeHead(404).end('not found');
109
+ return;
110
+ }
111
+ const code = url.searchParams.get('code');
112
+ const returnedState = url.searchParams.get('state');
113
+ const fail = (message, httpBody) => {
114
+ res.writeHead(400, { 'Content-Type': 'text/html' }).end(httpBody);
115
+ spinner.fail(message);
116
+ server.close();
117
+ reject(new Error(message));
118
+ };
119
+ if (returnedState !== state)
120
+ return fail('State mismatch (possible CSRF) — aborting.', '<h2>State mismatch. Restart the flow.</h2>');
121
+ if (!code)
122
+ return fail(`Authorization denied: ${url.searchParams.get('error') ?? 'no code returned'}.`, '<h2>No authorization code returned.</h2>');
123
+ try {
124
+ const tokenRes = await fetch(`https://${store}/admin/oauth/access_token`, {
125
+ // Shopify's token endpoint requires these exact snake_case keys.
126
+ // eslint-disable-next-line camelcase
127
+ body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, code }),
128
+ headers: { 'Content-Type': 'application/json' },
129
+ method: 'POST',
130
+ });
131
+ const text = await tokenRes.text();
132
+ if (!tokenRes.ok)
133
+ return fail(`Token exchange failed (${tokenRes.status}): ${text.slice(0, 200)}`, '<h2>Token exchange failed. Check the terminal.</h2>');
134
+ const data = JSON.parse(text);
135
+ res.writeHead(200, { 'Content-Type': 'text/html' }).end('<h2>✅ Token captured. Return to the terminal.</h2>');
136
+ spinner.succeed('Authorization complete.');
137
+ server.close();
138
+ resolve(data.access_token);
139
+ }
140
+ catch (error) {
141
+ fail(`Error during token exchange: ${error.message}`, '<h2>Error. Check the terminal.</h2>');
142
+ }
143
+ });
144
+ server.on('error', (error) => {
145
+ const message = error.code === 'EADDRINUSE'
146
+ ? `Port ${port} is already in use. Pass a different --port (and update the app redirect URL).`
147
+ : error.message;
148
+ reject(new Error(message));
149
+ });
150
+ server.listen(port, () => {
151
+ this.log(chalk.dim('Open this URL to authorize (logged into the store admin):'));
152
+ this.log(' ' + chalk.cyan(authorizeUrl));
153
+ if (open)
154
+ openBrowser(authorizeUrl);
155
+ spinner.start();
156
+ });
157
+ });
158
+ }
159
+ writeMcpConfig(path, store, token) {
160
+ let json;
161
+ try {
162
+ json = JSON.parse(readFileSync(path, 'utf8'));
163
+ }
164
+ catch (error) {
165
+ this.error(`Could not read ${path}: ${error.message}`, { code: '1' });
166
+ }
167
+ json.mcpServers ??= {};
168
+ json.mcpServers.shopify = {
169
+ args: ['-y', 'shopify-graphql-admin-mcp', '--store', store, '--access-token', token],
170
+ command: 'npx',
171
+ };
172
+ writeFileSync(path, JSON.stringify(json, null, 2) + '\n');
173
+ }
174
+ }
@@ -39,7 +39,88 @@
39
39
  "commands",
40
40
  "create-app.js"
41
41
  ]
42
+ },
43
+ "token": {
44
+ "aliases": [],
45
+ "args": {},
46
+ "description": "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.",
47
+ "examples": [
48
+ "<%= config.bin %> <%= command.id %> --store trafalgar-store --client-id 0b42... --client-secret shpss_...",
49
+ "<%= config.bin %> <%= command.id %> -s mystore -i <id> -c <secret> --scopes read_products,read_orders",
50
+ "<%= config.bin %> <%= command.id %> -s mystore -i <id> -c <secret> --write ./.mcp.json"
51
+ ],
52
+ "flags": {
53
+ "client-id": {
54
+ "char": "i",
55
+ "description": "app Client ID",
56
+ "name": "client-id",
57
+ "hasDynamicHelp": false,
58
+ "multiple": false,
59
+ "type": "option"
60
+ },
61
+ "client-secret": {
62
+ "char": "c",
63
+ "description": "app Client secret (shpss_...)",
64
+ "name": "client-secret",
65
+ "hasDynamicHelp": false,
66
+ "multiple": false,
67
+ "type": "option"
68
+ },
69
+ "no-open": {
70
+ "description": "don't auto-open the authorize URL in a browser",
71
+ "name": "no-open",
72
+ "allowNo": false,
73
+ "type": "boolean"
74
+ },
75
+ "port": {
76
+ "char": "p",
77
+ "description": "local callback port (must match the app redirect URL)",
78
+ "name": "port",
79
+ "default": 3456,
80
+ "hasDynamicHelp": false,
81
+ "multiple": false,
82
+ "type": "option"
83
+ },
84
+ "scopes": {
85
+ "description": "comma-separated Admin API scopes to request",
86
+ "name": "scopes",
87
+ "default": "read_products",
88
+ "hasDynamicHelp": false,
89
+ "multiple": false,
90
+ "type": "option"
91
+ },
92
+ "store": {
93
+ "char": "s",
94
+ "description": "store domain (mystore or mystore.myshopify.com)",
95
+ "name": "store",
96
+ "hasDynamicHelp": false,
97
+ "multiple": false,
98
+ "type": "option"
99
+ },
100
+ "write": {
101
+ "char": "w",
102
+ "description": "path to an .mcp.json whose 'shopify' server should be rewritten to use the token",
103
+ "name": "write",
104
+ "hasDynamicHelp": false,
105
+ "multiple": false,
106
+ "type": "option"
107
+ }
108
+ },
109
+ "hasDynamicHelp": false,
110
+ "hiddenAliases": [],
111
+ "id": "token",
112
+ "pluginAlias": "@firenet-designs/fnd-cli",
113
+ "pluginName": "@firenet-designs/fnd-cli",
114
+ "pluginType": "core",
115
+ "strict": true,
116
+ "enableJsonFlag": false,
117
+ "isESM": true,
118
+ "relativePath": [
119
+ "dist",
120
+ "commands",
121
+ "token.js"
122
+ ]
42
123
  }
43
124
  },
44
- "version": "1.1.1"
125
+ "version": "2.0.0"
45
126
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@firenet-designs/fnd-cli",
3
3
  "description": "A new CLI generated with oclif",
4
- "version": "1.1.1",
4
+ "version": "2.0.0",
5
5
  "author": "Cole Denslow",
6
6
  "bin": {
7
7
  "fnd": "bin/run.js"
@@ -21,7 +21,7 @@
21
21
  "@oclif/test": "^4",
22
22
  "@types/chai": "^4",
23
23
  "@types/mocha": "^10",
24
- "@types/node": "^18.19.130",
24
+ "@types/node": "^24.13.1",
25
25
  "chai": "^4",
26
26
  "eslint": "^9",
27
27
  "eslint-config-oclif": "^6",
@@ -33,7 +33,7 @@
33
33
  "typescript": "^5"
34
34
  },
35
35
  "engines": {
36
- "node": ">=18.0.0"
36
+ "node": ">=24.0.0"
37
37
  },
38
38
  "files": [
39
39
  "./bin",
@@ -46,6 +46,10 @@
46
46
  ],
47
47
  "license": "MIT",
48
48
  "main": "dist/index.js",
49
+ "publishConfig": {
50
+ "access": "public",
51
+ "registry": "https://registry.npmjs.org/"
52
+ },
49
53
  "type": "module",
50
54
  "oclif": {
51
55
  "bin": "fnd-cli",