@firenet-designs/fnd-cli 1.1.1 → 2.1.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.1.0 linux-x64 node-v24.12.0
24
24
  $ fnd-cli --help [COMMAND]
25
25
  USAGE
26
26
  $ fnd-cli COMMAND
@@ -29,8 +29,38 @@ USAGE
29
29
  <!-- usagestop -->
30
30
  # Commands
31
31
  <!-- commands -->
32
+ * [`fnd-cli backfill-project [SHOP]`](#fnd-cli-backfill-project-shop)
32
33
  * [`fnd-cli create-app`](#fnd-cli-create-app)
34
+ * [`fnd-cli create-project [SHOP]`](#fnd-cli-create-project-shop)
33
35
  * [`fnd-cli help [COMMAND]`](#fnd-cli-help-command)
36
+ * [`fnd-cli token`](#fnd-cli-token)
37
+
38
+ ## `fnd-cli backfill-project [SHOP]`
39
+
40
+ 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.
41
+
42
+ ```
43
+ USAGE
44
+ $ fnd-cli backfill-project [SHOP...]
45
+
46
+ ARGUMENTS
47
+ [SHOP...] Shopify store handle, passed to Claude as a hint
48
+
49
+ DESCRIPTION
50
+ Backfill missing scaffold files in an EXISTING repo + theme: .gitignore, .shopifyignore, CLAUDE.md. Each is created
51
+ only if absent — existing files are left untouched. Does NOT git init, pull the theme, commit, or touch GitHub.
52
+
53
+ Requires the claude CLI (npm install -g @anthropic-ai/claude-code) for the CLAUDE.md step.
54
+
55
+ EXAMPLES
56
+ $ fnd-cli backfill-project
57
+
58
+ $ fnd-cli backfill-project my-store
59
+
60
+ $ fnd-cli backfill-project my-store "extra hints"
61
+ ```
62
+
63
+ _See code: [src/commands/backfill-project.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.1.0/src/commands/backfill-project.ts)_
34
64
 
35
65
  ## `fnd-cli create-app`
36
66
 
@@ -51,7 +81,37 @@ EXAMPLES
51
81
  $ fnd-cli create-app
52
82
  ```
53
83
 
54
- _See code: [src/commands/create-app.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v1.1.1/src/commands/create-app.ts)_
84
+ _See code: [src/commands/create-app.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.1.0/src/commands/create-app.ts)_
85
+
86
+ ## `fnd-cli create-project [SHOP]`
87
+
88
+ 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.
89
+
90
+ ```
91
+ USAGE
92
+ $ fnd-cli create-project [SHOP...]
93
+
94
+ ARGUMENTS
95
+ [SHOP...] Shopify store handle — pulls the live theme from <shop>.myshopify.com (omit to skip)
96
+
97
+ DESCRIPTION
98
+ Scaffold a new client project: git on branch production, ignore files, Shopify theme pull, a Claude-generated
99
+ CLAUDE.md, then a private GitHub repo under the FireNet-Designs org.
100
+
101
+ Requires the claude CLI (npm install -g @anthropic-ai/claude-code) for the CLAUDE.md step.
102
+
103
+ GitHub auth comes from YOUR environment — run `gh auth login` once, or export GH_TOKEN in your shell profile. This CLI
104
+ never stores credentials. Override the org with FND_GH_ORG.
105
+
106
+ EXAMPLES
107
+ $ fnd-cli create-project
108
+
109
+ $ fnd-cli create-project my-store
110
+
111
+ $ fnd-cli create-project my-store "Acme storefront, Klaviyo"
112
+ ```
113
+
114
+ _See code: [src/commands/create-project.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.1.0/src/commands/create-project.ts)_
55
115
 
56
116
  ## `fnd-cli help [COMMAND]`
57
117
 
@@ -72,4 +132,36 @@ DESCRIPTION
72
132
  ```
73
133
 
74
134
  _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.2.37/src/commands/help.ts)_
135
+
136
+ ## `fnd-cli token`
137
+
138
+ 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.
139
+
140
+ ```
141
+ USAGE
142
+ $ fnd-cli token [-i <value>] [-c <value>] [--no-open] [-p <value>] [--scopes <value>] [-s <value>] [-w
143
+ <value>]
144
+
145
+ FLAGS
146
+ -c, --client-secret=<value> app Client secret (shpss_...)
147
+ -i, --client-id=<value> app Client ID
148
+ -p, --port=<value> [default: 3456] local callback port (must match the app redirect URL)
149
+ -s, --store=<value> store domain (mystore or mystore.myshopify.com)
150
+ -w, --write=<value> path to an .mcp.json whose 'shopify' server should be rewritten to use the token
151
+ --no-open don't auto-open the authorize URL in a browser
152
+ --scopes=<value> [default: read_products] comma-separated Admin API scopes to request
153
+
154
+ DESCRIPTION
155
+ Mint an offline Shopify Admin API access token (shpat_) for a custom app via the OAuth authorization-code grant. Works
156
+ cross-org, unlike client_credentials.
157
+
158
+ EXAMPLES
159
+ $ fnd-cli token --store trafalgar-store --client-id 0b42... --client-secret shpss_...
160
+
161
+ $ fnd-cli token -s mystore -i <id> -c <secret> --scopes read_products,read_orders
162
+
163
+ $ fnd-cli token -s mystore -i <id> -c <secret> --write ./.mcp.json
164
+ ```
165
+
166
+ _See code: [src/commands/token.ts](https://github.com/FireNet-Designs/fnd-cli/blob/v2.1.0/src/commands/token.ts)_
75
167
  <!-- 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
+ }
@@ -0,0 +1,22 @@
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
+ /** Loud, boxed reminder of the one-time Dev Dashboard setup. */
17
+ private printSetupBanner;
18
+ /** Minimal readline prompt — keeps the CLI dependency-free. */
19
+ private prompt;
20
+ private runOAuthFlow;
21
+ private writeMcpConfig;
22
+ }
@@ -0,0 +1,224 @@
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.printSetupBanner(redirectUri, scopes);
54
+ const token = await this.runOAuthFlow({ clientId, clientSecret, open: !flags['no-open'], port, redirectUri, scopes, store });
55
+ this.log('');
56
+ this.log(chalk.green('✓ Offline access token (never expires until uninstall/secret rotation):'));
57
+ this.log(' ' + chalk.bold(token));
58
+ this.log('');
59
+ this.log(chalk.dim('Drop-in .mcp.json server config:'));
60
+ this.log(this.mcpSnippet(store, token));
61
+ if (flags.write) {
62
+ this.writeMcpConfig(flags.write, store, token);
63
+ this.log('');
64
+ this.log(chalk.green(`✓ Updated ${flags.write} (shopify server -> --access-token).`));
65
+ }
66
+ }
67
+ mcpSnippet(store, token) {
68
+ const config = {
69
+ mcpServers: {
70
+ shopify: {
71
+ args: ['-y', 'shopify-graphql-admin-mcp', '--store', store, '--access-token', token],
72
+ command: 'npx',
73
+ },
74
+ },
75
+ };
76
+ return chalk.dim(JSON.stringify(config, null, 2));
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
+ }
109
+ /** Minimal readline prompt — keeps the CLI dependency-free. */
110
+ async prompt(label) {
111
+ const rl = createInterface({ input: stdin, output: stdout });
112
+ try {
113
+ const answer = (await rl.question(`${label}: `)).trim();
114
+ if (!answer)
115
+ this.error(`${label} is required`, { code: '1' });
116
+ return answer;
117
+ }
118
+ finally {
119
+ rl.close();
120
+ }
121
+ }
122
+ runOAuthFlow(opts) {
123
+ const { clientId, clientSecret, open, port, redirectUri, scopes, store } = opts;
124
+ const state = randomBytes(16).toString('hex');
125
+ const authorizeUrl = `https://${store}/admin/oauth/authorize` +
126
+ `?client_id=${encodeURIComponent(clientId)}` +
127
+ `&scope=${encodeURIComponent(scopes)}` +
128
+ `&redirect_uri=${encodeURIComponent(redirectUri)}` +
129
+ `&state=${state}`;
130
+ const spinner = ora(`Waiting for authorization at ${redirectUri} …`);
131
+ return new Promise((resolve, reject) => {
132
+ const server = createServer(async (req, res) => {
133
+ const url = new URL(req.url ?? '/', `http://localhost:${port}`);
134
+ if (url.pathname !== '/callback') {
135
+ res.writeHead(404).end('not found');
136
+ return;
137
+ }
138
+ const code = url.searchParams.get('code');
139
+ const returnedState = url.searchParams.get('state');
140
+ const oauthError = url.searchParams.get('error');
141
+ const fail = (message, httpBody) => {
142
+ res.writeHead(400, { 'Content-Type': 'text/html' }).end(httpBody);
143
+ spinner.fail(message);
144
+ server.close();
145
+ reject(new Error(message));
146
+ };
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>');
162
+ if (!code)
163
+ return fail('No authorization code returned.', '<h2>No authorization code returned.</h2>');
164
+ try {
165
+ const tokenRes = await fetch(`https://${store}/admin/oauth/access_token`, {
166
+ // Shopify's token endpoint requires these exact snake_case keys.
167
+ // eslint-disable-next-line camelcase
168
+ body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, code }),
169
+ headers: { 'Content-Type': 'application/json' },
170
+ method: 'POST',
171
+ });
172
+ const text = await tokenRes.text();
173
+ if (!tokenRes.ok)
174
+ return fail(`Token exchange failed (${tokenRes.status}): ${text.slice(0, 200)}`, '<h2>Token exchange failed. Check the terminal.</h2>');
175
+ const data = JSON.parse(text);
176
+ res.writeHead(200, { 'Content-Type': 'text/html' }).end('<h2>✅ Token captured. Return to the terminal.</h2>');
177
+ spinner.succeed('Authorization complete.');
178
+ server.close();
179
+ resolve(data.access_token);
180
+ }
181
+ catch (error) {
182
+ fail(`Error during token exchange: ${error.message}`, '<h2>Error. Check the terminal.</h2>');
183
+ }
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));
193
+ server.on('error', (error) => {
194
+ clearTimeout(timer);
195
+ const message = error.code === 'EADDRINUSE'
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).`
197
+ : error.message;
198
+ reject(new Error(message));
199
+ });
200
+ server.listen(port, () => {
201
+ this.log(chalk.dim('Open this URL to authorize (logged into the store admin):'));
202
+ this.log(' ' + chalk.cyan(authorizeUrl));
203
+ if (open)
204
+ openBrowser(authorizeUrl);
205
+ spinner.start();
206
+ });
207
+ });
208
+ }
209
+ writeMcpConfig(path, store, token) {
210
+ let json;
211
+ try {
212
+ json = JSON.parse(readFileSync(path, 'utf8'));
213
+ }
214
+ catch (error) {
215
+ this.error(`Could not read ${path}: ${error.message}`, { code: '1' });
216
+ }
217
+ json.mcpServers ??= {};
218
+ json.mcpServers.shopify = {
219
+ args: ['-y', 'shopify-graphql-admin-mcp', '--store', store, '--access-token', token],
220
+ command: 'npx',
221
+ };
222
+ writeFileSync(path, JSON.stringify(json, null, 2) + '\n');
223
+ }
224
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shared scaffolding pieces for `create-project` and `backfill-project`.
3
+ *
4
+ * SECURITY: nothing in this module (or the commands that use it) may ever
5
+ * contain, read into a string, or print a credential. GitHub auth comes from
6
+ * the user's own environment (GH_TOKEN / GITHUB_TOKEN exported in their shell,
7
+ * or `gh auth login`) — we only check that SOME auth exists, never the value.
8
+ */
9
+ export declare const GITIGNORE_CONTENT = ".idea/\nnode_modules/\nconfig.yml\n";
10
+ export declare const SHOPIFYIGNORE_CONTENT = "src/\nscripts/\nsrc\nscripts\nconfig.yml\n";
11
+ /**
12
+ * Prompt that drives CLAUDE.md generation. Ships with this package in
13
+ * prompts/ (resolved relative to this module: dist/lib -> <pkg root>/prompts).
14
+ * Set FND_INIT_PROMPT_PATH to test a local prompt without republishing.
15
+ */
16
+ export declare const initPromptPath: () => string;
17
+ /**
18
+ * Runs a command with stdio inherited so the user sees live output and can
19
+ * answer interactive prompts (shopify auth, claude, gh, git/ssh).
20
+ */
21
+ export declare const run: (command: string, args: string[], env?: NodeJS.ProcessEnv) => Promise<number>;
22
+ /** Resolves a binary on PATH, or null if not found. */
23
+ export declare const which: (bin: string) => null | string;
24
+ /**
25
+ * Finds the shopify CLI: PATH first, then nvm node versions (newest wins) —
26
+ * shopify is often installed per nvm node version that isn't currently active.
27
+ */
28
+ export declare const findShopifyBin: () => null | string;
29
+ /**
30
+ * Builds the CLAUDE.md generation prompt and runs `claude -p` with it.
31
+ * Returns 'claude-not-installed' when the claude CLI isn't on PATH,
32
+ * 'missing-prompt' when the bundled prompt file is gone (broken install),
33
+ * otherwise 'written'/'not-written' depending on whether CLAUDE.md appeared.
34
+ */
35
+ export declare const generateClaudeMd: (shop: string | undefined, extraHints: string) => Promise<"claude-not-installed" | "missing-prompt" | "not-written" | "written">;
@@ -0,0 +1,101 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ /**
7
+ * Shared scaffolding pieces for `create-project` and `backfill-project`.
8
+ *
9
+ * SECURITY: nothing in this module (or the commands that use it) may ever
10
+ * contain, read into a string, or print a credential. GitHub auth comes from
11
+ * the user's own environment (GH_TOKEN / GITHUB_TOKEN exported in their shell,
12
+ * or `gh auth login`) — we only check that SOME auth exists, never the value.
13
+ */
14
+ export const GITIGNORE_CONTENT = `.idea/
15
+ node_modules/
16
+ config.yml
17
+ `;
18
+ export const SHOPIFYIGNORE_CONTENT = `src/
19
+ scripts/
20
+ src
21
+ scripts
22
+ config.yml
23
+ `;
24
+ /**
25
+ * Prompt that drives CLAUDE.md generation. Ships with this package in
26
+ * prompts/ (resolved relative to this module: dist/lib -> <pkg root>/prompts).
27
+ * Set FND_INIT_PROMPT_PATH to test a local prompt without republishing.
28
+ */
29
+ export const initPromptPath = () => {
30
+ const override = process.env.FND_INIT_PROMPT_PATH;
31
+ if (override && existsSync(override))
32
+ return override;
33
+ return fileURLToPath(new URL('../../prompts/init-project-auto.md', import.meta.url));
34
+ };
35
+ /**
36
+ * Runs a command with stdio inherited so the user sees live output and can
37
+ * answer interactive prompts (shopify auth, claude, gh, git/ssh).
38
+ */
39
+ export const run = (command, args, env) => new Promise((resolve, reject) => {
40
+ const child = spawn(command, args, { env: env ?? process.env, stdio: 'inherit' });
41
+ child.on('error', reject);
42
+ child.on('close', (code) => resolve(code ?? 1));
43
+ });
44
+ /** Resolves a binary on PATH, or null if not found. */
45
+ export const which = (bin) => {
46
+ const result = spawnSync('which', [bin], { encoding: 'utf8' });
47
+ return result.status === 0 ? result.stdout.trim() : null;
48
+ };
49
+ const parseNodeVersion = (v) => v.replace(/^v/, '').split('.').map(Number);
50
+ /** `sort -V` equivalent for nvm dir names like `v24.13.1`. */
51
+ const compareNodeVersions = (a, b) => {
52
+ const [aParts, bParts] = [parseNodeVersion(a), parseNodeVersion(b)];
53
+ for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
54
+ const diff = (aParts[i] ?? 0) - (bParts[i] ?? 0);
55
+ if (diff !== 0)
56
+ return diff;
57
+ }
58
+ return 0;
59
+ };
60
+ /**
61
+ * Finds the shopify CLI: PATH first, then nvm node versions (newest wins) —
62
+ * shopify is often installed per nvm node version that isn't currently active.
63
+ */
64
+ export const findShopifyBin = () => {
65
+ const onPath = which('shopify');
66
+ if (onPath)
67
+ return onPath;
68
+ const nodeVersionsDir = join(homedir(), '.nvm', 'versions', 'node');
69
+ if (!existsSync(nodeVersionsDir))
70
+ return null;
71
+ const versions = readdirSync(nodeVersionsDir)
72
+ .filter((v) => existsSync(join(nodeVersionsDir, v, 'bin', 'shopify')))
73
+ .sort(compareNodeVersions);
74
+ const newest = versions.at(-1);
75
+ return newest ? join(nodeVersionsDir, newest, 'bin', 'shopify') : null;
76
+ };
77
+ /**
78
+ * Builds the CLAUDE.md generation prompt and runs `claude -p` with it.
79
+ * Returns 'claude-not-installed' when the claude CLI isn't on PATH,
80
+ * 'missing-prompt' when the bundled prompt file is gone (broken install),
81
+ * otherwise 'written'/'not-written' depending on whether CLAUDE.md appeared.
82
+ */
83
+ export const generateClaudeMd = async (shop, extraHints) => {
84
+ if (!which('claude'))
85
+ return 'claude-not-installed';
86
+ const promptPath = initPromptPath();
87
+ if (!existsSync(promptPath))
88
+ return 'missing-prompt';
89
+ let prompt = readFileSync(promptPath, 'utf8');
90
+ let hints = extraHints;
91
+ if (shop)
92
+ hints = `Shopify store: ${shop}. ${hints}`;
93
+ if (hints.trim())
94
+ prompt += `\n\n## User hints (override inference)\n${hints}`;
95
+ await run('claude', [
96
+ '-p', prompt,
97
+ '--permission-mode', 'acceptEdits',
98
+ '--allowedTools', 'Read Glob Grep Bash Write Edit',
99
+ ]);
100
+ return existsSync(join(process.cwd(), 'CLAUDE.md')) ? 'written' : 'not-written';
101
+ };
@@ -1,5 +1,36 @@
1
1
  {
2
2
  "commands": {
3
+ "backfill-project": {
4
+ "aliases": [],
5
+ "args": {
6
+ "shop": {
7
+ "description": "Shopify store handle, passed to Claude as a hint",
8
+ "name": "shop",
9
+ "required": false
10
+ }
11
+ },
12
+ "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.",
13
+ "examples": [
14
+ "<%= config.bin %> <%= command.id %>",
15
+ "<%= config.bin %> <%= command.id %> my-store",
16
+ "<%= config.bin %> <%= command.id %> my-store \"extra hints\""
17
+ ],
18
+ "flags": {},
19
+ "hasDynamicHelp": false,
20
+ "hiddenAliases": [],
21
+ "id": "backfill-project",
22
+ "pluginAlias": "@firenet-designs/fnd-cli",
23
+ "pluginName": "@firenet-designs/fnd-cli",
24
+ "pluginType": "core",
25
+ "strict": false,
26
+ "enableJsonFlag": false,
27
+ "isESM": true,
28
+ "relativePath": [
29
+ "dist",
30
+ "commands",
31
+ "backfill-project.js"
32
+ ]
33
+ },
3
34
  "create-app": {
4
35
  "aliases": [],
5
36
  "args": {},
@@ -39,7 +70,119 @@
39
70
  "commands",
40
71
  "create-app.js"
41
72
  ]
73
+ },
74
+ "create-project": {
75
+ "aliases": [],
76
+ "args": {
77
+ "shop": {
78
+ "description": "Shopify store handle — pulls the live theme from <shop>.myshopify.com (omit to skip)",
79
+ "name": "shop",
80
+ "required": false
81
+ }
82
+ },
83
+ "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.",
84
+ "examples": [
85
+ "<%= config.bin %> <%= command.id %>",
86
+ "<%= config.bin %> <%= command.id %> my-store",
87
+ "<%= config.bin %> <%= command.id %> my-store \"Acme storefront, Klaviyo\""
88
+ ],
89
+ "flags": {},
90
+ "hasDynamicHelp": false,
91
+ "hiddenAliases": [],
92
+ "id": "create-project",
93
+ "pluginAlias": "@firenet-designs/fnd-cli",
94
+ "pluginName": "@firenet-designs/fnd-cli",
95
+ "pluginType": "core",
96
+ "strict": false,
97
+ "enableJsonFlag": false,
98
+ "isESM": true,
99
+ "relativePath": [
100
+ "dist",
101
+ "commands",
102
+ "create-project.js"
103
+ ]
104
+ },
105
+ "token": {
106
+ "aliases": [],
107
+ "args": {},
108
+ "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.",
109
+ "examples": [
110
+ "<%= config.bin %> <%= command.id %> --store trafalgar-store --client-id 0b42... --client-secret shpss_...",
111
+ "<%= config.bin %> <%= command.id %> -s mystore -i <id> -c <secret> --scopes read_products,read_orders",
112
+ "<%= config.bin %> <%= command.id %> -s mystore -i <id> -c <secret> --write ./.mcp.json"
113
+ ],
114
+ "flags": {
115
+ "client-id": {
116
+ "char": "i",
117
+ "description": "app Client ID",
118
+ "name": "client-id",
119
+ "hasDynamicHelp": false,
120
+ "multiple": false,
121
+ "type": "option"
122
+ },
123
+ "client-secret": {
124
+ "char": "c",
125
+ "description": "app Client secret (shpss_...)",
126
+ "name": "client-secret",
127
+ "hasDynamicHelp": false,
128
+ "multiple": false,
129
+ "type": "option"
130
+ },
131
+ "no-open": {
132
+ "description": "don't auto-open the authorize URL in a browser",
133
+ "name": "no-open",
134
+ "allowNo": false,
135
+ "type": "boolean"
136
+ },
137
+ "port": {
138
+ "char": "p",
139
+ "description": "local callback port (must match the app redirect URL)",
140
+ "name": "port",
141
+ "default": 3456,
142
+ "hasDynamicHelp": false,
143
+ "multiple": false,
144
+ "type": "option"
145
+ },
146
+ "scopes": {
147
+ "description": "comma-separated Admin API scopes to request",
148
+ "name": "scopes",
149
+ "default": "read_products",
150
+ "hasDynamicHelp": false,
151
+ "multiple": false,
152
+ "type": "option"
153
+ },
154
+ "store": {
155
+ "char": "s",
156
+ "description": "store domain (mystore or mystore.myshopify.com)",
157
+ "name": "store",
158
+ "hasDynamicHelp": false,
159
+ "multiple": false,
160
+ "type": "option"
161
+ },
162
+ "write": {
163
+ "char": "w",
164
+ "description": "path to an .mcp.json whose 'shopify' server should be rewritten to use the token",
165
+ "name": "write",
166
+ "hasDynamicHelp": false,
167
+ "multiple": false,
168
+ "type": "option"
169
+ }
170
+ },
171
+ "hasDynamicHelp": false,
172
+ "hiddenAliases": [],
173
+ "id": "token",
174
+ "pluginAlias": "@firenet-designs/fnd-cli",
175
+ "pluginName": "@firenet-designs/fnd-cli",
176
+ "pluginType": "core",
177
+ "strict": true,
178
+ "enableJsonFlag": false,
179
+ "isESM": true,
180
+ "relativePath": [
181
+ "dist",
182
+ "commands",
183
+ "token.js"
184
+ ]
42
185
  }
43
186
  },
44
- "version": "1.1.1"
187
+ "version": "2.1.0"
45
188
  }
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.1.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,11 +33,12 @@
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",
40
40
  "./dist",
41
+ "./prompts",
41
42
  "./oclif.manifest.json"
42
43
  ],
43
44
  "homepage": "https://github.com/FireNet-Designs/fnd-cli",
@@ -46,6 +47,10 @@
46
47
  ],
47
48
  "license": "MIT",
48
49
  "main": "dist/index.js",
50
+ "publishConfig": {
51
+ "access": "public",
52
+ "registry": "https://registry.npmjs.org/"
53
+ },
49
54
  "type": "module",
50
55
  "oclif": {
51
56
  "bin": "fnd-cli",
@@ -0,0 +1,183 @@
1
+ # Init Project (Auto) — Generate & Write CLAUDE.md, No Questions
2
+
3
+ You are generating a `CLAUDE.md` for the code repo in the **current working directory** and writing it to disk at `./CLAUDE.md`. This is a non-interactive, one-shot run.
4
+
5
+ ## Hard rules
6
+
7
+ - **Do NOT ask the user anything.** There is no human watching this run. Infer every field from the repo. Where you genuinely cannot infer a value, use the sensible default noted below — never a placeholder like `[fill in]`.
8
+ - **Write the file yourself** to `./CLAUDE.md` using the Write tool. Do not print it in a code block and stop.
9
+ - If `./CLAUDE.md` already exists, read it first and treat the run as an update: keep any human-written project notes, refresh the inferred sections.
10
+ - Keep the whole thing tight. This file is loaded on every future session in the repo — no filler.
11
+
12
+ ## Step 1 — Inspect the repo (read-only)
13
+
14
+ Look at the current directory to infer the fields. Useful signals:
15
+
16
+ - `package.json` → name, scripts (dev/test/lint/typecheck/build/deploy), dependencies (stack + runtime via `engines`), entry (`main`/`module`/`exports`).
17
+ - `shopify.app.toml` → **Shopify App**. Look for an `extensions/` dir → note each extension and its `shopify.extension.toml`.
18
+ - `shopify.theme.toml`, or top-level `templates/ sections/ snippets/ layout/ config/` → **Shopify Theme**.
19
+ - `@shopify/hydrogen` in deps, or `app/routes/` + `remix`/`vite` → **Shopify Hydrogen**.
20
+ - `next.config.*` or `next` in deps → **Next.js**.
21
+ - An Express/Fastify/Hono server entry with no frontend → **Node API**.
22
+ - Anything else → **Custom** (write 4–6 conventions tailored to the actual stack).
23
+ - `tsconfig.json`, lockfile type (`package-lock.json`/`pnpm-lock.yaml`/`yarn.lock`/`bun.lockb`), `.nvmrc`/`engines` → runtime + package manager.
24
+ - `.env.example` / `.env.sample` → external services (Klaviyo, Stripe, Shopify Admin API, a DB URL, etc.).
25
+ - Top two levels of the directory tree → Project Structure block.
26
+
27
+ Defaults when a signal is missing: Type = `Custom`; Runtime = latest LTS Node; Build output = `none`; External Services = omit the section; commands = omit rows that don't exist.
28
+
29
+ ## Step 2 — Write `./CLAUDE.md` with exactly this structure
30
+
31
+ ```
32
+ # [Project Name]
33
+
34
+ **Type:** [Project Type]
35
+ **Stack:** [Stack — comma separated]
36
+ **Runtime:** [Runtime + package manager]
37
+
38
+ ## What This Is
39
+
40
+ [One or two sentences inferred from README/package.json description. If none, describe it from the code.]
41
+
42
+ ---
43
+
44
+ ## Engineering Principles
45
+
46
+ These apply to every file touched in this project.
47
+
48
+ | Principle | Rule |
49
+ |-----------|------|
50
+ | Separation of concerns | One module, one reason to change |
51
+ | No side effects at the core | Pure logic in the middle, IO/network/DB/filesystem only at the edges |
52
+ | Open/Closed | Extend by adding — modify existing code only to fix bugs, never for a new caller |
53
+ | WET before DRY | Duplicate until three identical-shape instances, then extract |
54
+ | No unnecessary abstraction | If you can delete it and nothing breaks, delete it |
55
+ | Boundary validation only | Validate user input and external API responses — trust internal code |
56
+ | Comments explain WHY | Not what. Never describe what the code does. |
57
+
58
+ ---
59
+
60
+ ## Project Structure
61
+
62
+ ```
63
+ [Top two levels of the directory tree]
64
+ ```
65
+
66
+ **Entry point:** `[entry point]`
67
+ **Build output:** `[build output]` — generated, never edit directly
68
+
69
+ ---
70
+
71
+ ## Conventions
72
+
73
+ [Insert the matching preset block below for the detected type. For Custom, write 4–6 stack-specific rules, one per line, imperative voice.]
74
+
75
+ ---
76
+
77
+ ## Workflow
78
+
79
+ | Task | Command |
80
+ |------|---------|
81
+ | Dev | `[dev]` |
82
+ | Test | `[test]` |
83
+ | Lint | `[lint]` |
84
+ | Type check | `[typecheck]` |
85
+ | Build | `[build]` |
86
+ | Deploy | `[deploy]` |
87
+
88
+ (Omit rows with no real command.)
89
+
90
+ ---
91
+
92
+ ## External Services
93
+
94
+ [One line per service + what it's for. Omit this whole section if none.]
95
+
96
+ ---
97
+
98
+ ## Package Security
99
+
100
+ - Never install a package younger than 14 days — let the community catch malicious releases first.
101
+ - Read every install command before approving the dependencies it pulls in.
102
+
103
+ ---
104
+
105
+ ## Agent Behavior
106
+
107
+ - **Never ask the user to test, run, or verify anything.** Claude owns the full dev loop — write, run, debug, fix, repeat until it works.
108
+ - If something genuinely cannot be verified (e.g. a visual UI state), say so explicitly. Never claim success without verifying.
109
+ - **Destructive commands require confirmation first.** Destructive = force push, `reset --hard`, deleting files/branches, dropping tables, removing packages, overwriting uncommitted work.
110
+ - Everything else: just run it. For complex or non-obvious commands, give one line of context — then run without waiting.
111
+
112
+ ---
113
+
114
+ ## Do Not Touch
115
+
116
+ - `[build output]` — generated output, never edit directly
117
+ - `.env` / `.env.*` — never read or write secret values; use `.env.example` for shape only
118
+ - Lockfiles — source of truth, never edit manually
119
+ - `node_modules/` — never edit
120
+ [Any extra off-limits dirs you spotted]
121
+ ```
122
+
123
+ ## Convention presets
124
+
125
+ ### Shopify Theme
126
+ ```
127
+ - Liquid templates live in `templates/`, `sections/`, `snippets/`, `layout/`
128
+ - CSS: `assets/[name].css`; JS: `assets/[name].js` — no bundler unless one is set up
129
+ - `config/settings_data.json` is written by Shopify admin — read but treat as volatile
130
+ - `config/settings_schema.json` is the source of truth for theme settings
131
+ - Never inline styles or scripts in Liquid — use asset references
132
+ - `{% schema %}` blocks must be valid JSON; verify with `shopify theme check`
133
+ ```
134
+
135
+ ### Shopify App
136
+ ```
137
+ - App entry: `[entry]`; Shopify CLI manages tunneling + dev environment
138
+ - `shopify.app.toml` — app config, commit it
139
+ - Extensions live in `extensions/[name]/`, each with its own `shopify.extension.toml`
140
+ - Never hardcode shop domain or access tokens — read from env or session
141
+ - Webhook handlers must verify HMAC before processing
142
+ ```
143
+
144
+ **If this is a Shopify App with extensions, ALSO append this block to the `Conventions` section verbatim:**
145
+ ```
146
+ ### Extension Constraints (hard platform limits)
147
+ - **No third-party libraries inside extension code.** Extensions run in a constrained sandbox — write plain code, no npm deps bundled into the extension.
148
+ - Hard limits per extension: **128 kB input / 11M instructions / 20 kB output.** Exceeding any of these fails the function at runtime.
149
+ - Keep extension logic minimal and allocation-free in hot paths; do work in the app backend where possible.
150
+ ```
151
+
152
+ ### Shopify Hydrogen
153
+ ```
154
+ - Remix file routing: `app/routes/` maps 1:1 to URL segments
155
+ - Loaders run server-side; actions handle mutations
156
+ - Storefront API queries live in `app/graphql/` as `.graphql` files
157
+ - Set cache headers in the loader return — never skip them on product/collection routes
158
+ - `public/` is served as-is; `app/assets/` goes through Vite
159
+ ```
160
+
161
+ ### Next.js
162
+ ```
163
+ - App Router: `app/`; `page.tsx` = route, `layout.tsx` = shell
164
+ - Server Components by default — add `"use client"` only when necessary
165
+ - API routes: `app/api/[route]/route.ts`
166
+ - Only `NEXT_PUBLIC_`-prefixed env vars are client-visible
167
+ - Always use `next/image` for content images, never raw `<img>`
168
+ - Never fetch in a Client Component when a Server Component can do it
169
+ ```
170
+
171
+ ### Node API
172
+ ```
173
+ - Route handlers in `src/routes/`, one file per resource
174
+ - Middleware in `src/middleware/`, applied in `src/app.ts`
175
+ - Validate request bodies at the route boundary only — not inside service functions
176
+ - Service functions are pure: typed inputs, typed outputs
177
+ - Database access only inside `src/db/` or `src/repositories/` — never in routes
178
+ - Environment config loaded once in `src/config.ts`
179
+ ```
180
+
181
+ ## Step 3 — Confirm
182
+
183
+ After writing, print one line: the absolute path of the file written and the detected project type. Nothing else.