@sonarsource/marketing-cli 1.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 ADDED
@@ -0,0 +1,180 @@
1
+ # @sonarsource/marketing-cli
2
+
3
+ CLI tooling for Sonar marketing sites — manages Kontent.ai environments, content-model migrations, and assembles local `.env` files from Netlify.
4
+
5
+ ## Prerequisites
6
+
7
+ - [Node.js](https://nodejs.org/) ≥ 22
8
+ - A **Netlify auth token** — run `netlify login` or set `NETLIFY_AUTH_TOKEN` ([docs](https://docs.netlify.com/cli/get-started/#obtain-a-token-in-the-netlify-ui))
9
+ - A **Kontent.ai management API key** — generate one in your project's [API keys settings](https://kontent.ai/learn/docs/apis/openapi/management-api-v2/#section/Authentication)
10
+
11
+ ## Installation
12
+
13
+ ```sh
14
+ yarn add -D @sonarsource/marketing-cli
15
+ ```
16
+
17
+ ## Configuration
18
+
19
+ Every consuming repo needs a `marketing.config.json` at its root. If you're migrating from the legacy CLI, `marketing init` generates it for you. Otherwise, create one manually:
20
+
21
+ ```jsonc
22
+ {
23
+ "kontent": {
24
+ // Display name shown in Kontent dashboard for the production environment
25
+ "productionEnvironmentName": "PRODUCTION_PROJECT",
26
+ // UUID of the production Kontent environment
27
+ "productionEnvironmentId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
28
+ // Role IDs to activate on cloned environments
29
+ "roles": ["xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"],
30
+ // Optional — declare long-lived branches (epics) and their children
31
+ "longLivedBranches": {},
32
+ },
33
+ "netlify": {
34
+ // Site ID for the preview/branch-deploy Netlify site
35
+ "previewSiteId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
36
+ // Site ID for the production Netlify site
37
+ "productionSiteId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
38
+ },
39
+ "envVars": {
40
+ // Env var names as they appear on Netlify and in .env files
41
+ "environmentId": "VITE_KONTENT_ENVIRONMENT_ID",
42
+ "secureKey": "KONTENT_SECURE_KEY",
43
+ "previewKey": "KONTENT_PREVIEW_KEY",
44
+ "homepage": "VITE_KONTENT_HOMEPAGE_CODENAME",
45
+ "previewMode": "VITE_KONTENT_PREVIEW_MODE",
46
+ "managementKey": "KONTENT_MANAGEMENT_KEY",
47
+ },
48
+ }
49
+ ```
50
+
51
+ ## Workflows
52
+
53
+ ### First-time repo onboarding
54
+
55
+ ```sh
56
+ # 1. Clone the repo and install dependencies
57
+ git clone <repo-url> && cd <repo>
58
+ yarn
59
+
60
+ # 2. Set your Netlify token (if not already) in your global profile (zshrc for example)
61
+ export NETLIFY_AUTH_TOKEN=<token>
62
+
63
+ # 3. Assemble .env files from Netlify
64
+ marketing setup
65
+ ```
66
+
67
+ `setup` fetches env vars from both the production and preview Netlify sites, resolves branch-scoped values when applicable, and writes `.env.development` and `.env.production`. A marker-delimited section inside each file is managed by the CLI — any keys you add outside the markers are left untouched.
68
+
69
+ ### Feature branch with content-model changes
70
+
71
+ When your work requires changes to the Kontent.ai content model (adding types, modifying snippets, etc.), clone a dedicated environment so your changes are isolated from production.
72
+
73
+ ```sh
74
+ # 1. Create a branch and clone a Kontent environment for it
75
+ git checkout -b feat/my-feature
76
+ marketing environment create
77
+
78
+ # 2. Scaffold a migration
79
+ marketing migration create add-hero-snippet
80
+
81
+ # 3. Edit the generated file in Migrations/, then run it
82
+ marketing migration run
83
+
84
+ # 4. Develop, iterate, run more migrations as needed
85
+ # ...
86
+
87
+ # 5. When the branch is ready to merge — migrate production and clean up
88
+ marketing environment finish
89
+ ```
90
+
91
+ `environment create` clones the source Kontent environment, pushes the clone's ID to Netlify as a branch-scoped env var, seeds `Migrations/status.json`, re-runs `setup`, and runs any pending migrations.
92
+
93
+ `environment finish` runs migrations on the source environment, deletes the clone, removes the Netlify branch var, and re-runs `setup`.
94
+
95
+ ### Long-lived branches (epics)
96
+
97
+ For work that spans multiple branches (an epic), declare the parent as a long-lived branch so child branches can clone from it instead of production.
98
+
99
+ ```sh
100
+ # 1. Create the epic branch with --stack
101
+ git checkout -b epic/redesign
102
+ marketing environment create --stack
103
+ ```
104
+
105
+ This clones from production, registers the branch in `longLivedBranches` in `marketing.config.json`, and records the environment ID. Commit this config change — the team needs to see it.
106
+
107
+ ```sh
108
+ # 2. Create child branches — the CLI asks which source to clone from
109
+ git checkout -b feat/new-nav
110
+ marketing environment create
111
+ # ? Select source environment:
112
+ # ❯ Production
113
+ # epic/redesign
114
+ ```
115
+
116
+ When you select a long-lived branch as the source, the CLI automatically adds the current branch to that source's `children` in config. No manual config editing is required — branch names are free.
117
+
118
+ You can also skip the selector with `--source`:
119
+
120
+ ```sh
121
+ marketing environment create --source epic/redesign
122
+ ```
123
+
124
+ Children are automatically removed from the parent's `children` on finish. Children must be finished before the parent:
125
+
126
+ ```sh
127
+ git checkout feat/new-nav
128
+ marketing environment finish # migrates back to the epic environment
129
+
130
+ git checkout epic/redesign
131
+ marketing environment finish # migrates back to production
132
+ ```
133
+
134
+ ## CI
135
+
136
+ All commands respect `--ci` (or the `CI` environment variable). In CI mode, interactive prompts are skipped — commands that need confirmation require `--yes`. Auth tokens and API keys are resolved from environment variables rather than local dotenv files.
137
+
138
+ ## Command reference
139
+
140
+ ### Global options
141
+
142
+ | Flag | Description |
143
+ | ------------------ | ----------------------------------------------------------------------------------------- |
144
+ | `--branch <name>` | Override the detected branch (defaults to `git` or `GITHUB_HEAD_REF` / `GITHUB_REF_NAME`) |
145
+ | `--ci` / `--no-ci` | Force CI mode on or off (defaults to the `CI` env var) |
146
+ | `--yes` | Skip confirmation prompts |
147
+
148
+ ### `marketing setup`
149
+
150
+ Assemble `.env.development` and `.env.production` from Netlify env vars. Headless — no prompts, safe to run repeatedly.
151
+
152
+ ### `marketing init`
153
+
154
+ One-time migration from the legacy CLI. Reads an existing `status.json`, generates `marketing.config.json`, and rewrites `status.json` to the new format.
155
+
156
+ | Flag | Description |
157
+ | ----------- | --------------------------------------------- |
158
+ | `--dry-run` | Print proposed output without writing files |
159
+ | `--force` | Overwrite an existing `marketing.config.json` |
160
+
161
+ ### `marketing environment create`
162
+
163
+ Clone a Kontent.ai environment for the current branch.
164
+
165
+ | Flag | Description |
166
+ | ------------------- | ------------------------------------------------------------------------------ |
167
+ | `--stack` | Declare this branch as a long-lived branch in config |
168
+ | `--source <branch>` | Clone from a specific long-lived branch instead of production (skips selector) |
169
+
170
+ ### `marketing environment finish`
171
+
172
+ Migrate the source environment, delete the clone, and clean up Netlify vars and status.
173
+
174
+ ### `marketing migration create <name>`
175
+
176
+ Scaffold a new timestamp-ordered TypeScript migration in `Migrations/`.
177
+
178
+ ### `marketing migration run`
179
+
180
+ Compile and run all pending migrations against the current branch's environment. Refuses to target production directly — use `environment finish` for that.
@@ -0,0 +1,14 @@
1
+ export interface BranchOptions {
2
+ branch?: string;
3
+ }
4
+ /**
5
+ * Detect the current branch. Precedence:
6
+ * 1. `--branch <name>` CLI option
7
+ * 2. `GITHUB_HEAD_REF` (PR builds)
8
+ * 3. `GITHUB_REF_NAME` (push builds)
9
+ * 4. `git symbolic-ref --short HEAD`
10
+ *
11
+ * Returns `undefined` on detached HEAD / unresolvable — callers decide how to
12
+ * handle that (e.g. fall back to global/production vars).
13
+ */
14
+ export declare function detectBranch(opts: BranchOptions): string | undefined;
package/dist/branch.js ADDED
@@ -0,0 +1,27 @@
1
+ import { execSync } from 'node:child_process';
2
+ /**
3
+ * Detect the current branch. Precedence:
4
+ * 1. `--branch <name>` CLI option
5
+ * 2. `GITHUB_HEAD_REF` (PR builds)
6
+ * 3. `GITHUB_REF_NAME` (push builds)
7
+ * 4. `git symbolic-ref --short HEAD`
8
+ *
9
+ * Returns `undefined` on detached HEAD / unresolvable — callers decide how to
10
+ * handle that (e.g. fall back to global/production vars).
11
+ */
12
+ export function detectBranch(opts) {
13
+ if (opts.branch)
14
+ return opts.branch;
15
+ const headRef = process.env['GITHUB_HEAD_REF'];
16
+ if (headRef)
17
+ return headRef;
18
+ const refName = process.env['GITHUB_REF_NAME'];
19
+ if (refName)
20
+ return refName;
21
+ try {
22
+ return execSync('git symbolic-ref --short HEAD', { encoding: 'utf8' }).trim();
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Branches that are considered protected (i.e. associated with production).
3
+ * Used by `migration run` and `environment` commands to guard against
4
+ * accidental production mutations.
5
+ */
6
+ export declare const PROTECTED_BRANCHES: readonly ["master", "main", "develop"];
7
+ /** Hardcoded folder name for Kontent.ai content-model migrations. */
8
+ export declare const MIGRATIONS_FOLDER = "Migrations";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Branches that are considered protected (i.e. associated with production).
3
+ * Used by `migration run` and `environment` commands to guard against
4
+ * accidental production mutations.
5
+ */
6
+ export const PROTECTED_BRANCHES = ['master', 'main', 'develop'];
7
+ /** Hardcoded folder name for Kontent.ai content-model migrations. */
8
+ export const MIGRATIONS_FOLDER = 'Migrations';
package/dist/ci.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ export interface CIOptions {
2
+ ci?: boolean;
3
+ }
4
+ /**
5
+ * Determine whether we're running in CI. The `--ci`/`--no-ci` flag overrides
6
+ * the environment variable when provided.
7
+ */
8
+ export declare function isCI(opts: CIOptions): boolean;
9
+ export interface ResolveManagementKeyOptions {
10
+ ci: boolean;
11
+ /** Value fetched from the Netlify preview site, if available */
12
+ netlifyValue: string | undefined;
13
+ /** Path to .env.development for local reads */
14
+ envFilePath: string;
15
+ /** The env var name for the management key (from config) */
16
+ managementKeyName: string;
17
+ }
18
+ /**
19
+ * Resolve the Kontent management API key by precedence:
20
+ * 1. `process.env.KONTENT_MANAGEMENT_KEY` (escape hatch)
21
+ * 2. CI → Netlify preview site value
22
+ * 3. Non-CI → read from local `.env.development`
23
+ * 4. Throw with actionable guidance
24
+ */
25
+ export declare function resolveManagementKey(opts: ResolveManagementKeyOptions): string;
package/dist/ci.js ADDED
@@ -0,0 +1,37 @@
1
+ import { readEnvValue } from './dotenv.js';
2
+ import { CliError } from './errors.js';
3
+ /**
4
+ * Determine whether we're running in CI. The `--ci`/`--no-ci` flag overrides
5
+ * the environment variable when provided.
6
+ */
7
+ export function isCI(opts) {
8
+ if (opts.ci !== undefined)
9
+ return opts.ci;
10
+ const ci = process.env['CI'];
11
+ return ci !== undefined && ci !== '' && ci !== 'false' && ci !== '0';
12
+ }
13
+ /**
14
+ * Resolve the Kontent management API key by precedence:
15
+ * 1. `process.env.KONTENT_MANAGEMENT_KEY` (escape hatch)
16
+ * 2. CI → Netlify preview site value
17
+ * 3. Non-CI → read from local `.env.development`
18
+ * 4. Throw with actionable guidance
19
+ */
20
+ export function resolveManagementKey(opts) {
21
+ // 1. Explicit env var always wins
22
+ const fromEnv = process.env['KONTENT_MANAGEMENT_KEY'];
23
+ if (fromEnv)
24
+ return fromEnv;
25
+ // 2. CI → Netlify preview site
26
+ if (opts.ci && opts.netlifyValue)
27
+ return opts.netlifyValue;
28
+ // 3. Local .env.development (non-CI, or CI fallback when Netlify value absent)
29
+ const fromFile = readEnvValue(opts.envFilePath, opts.managementKeyName);
30
+ if (fromFile)
31
+ return fromFile;
32
+ // 4. Nothing resolved
33
+ throw new CliError('Could not resolve the Kontent management API key.\n' +
34
+ ' • Set KONTENT_MANAGEMENT_KEY in your environment, or\n' +
35
+ ` • Add ${opts.managementKeyName}=<key> to ${opts.envFilePath}, or\n` +
36
+ ' • Run in CI with the key configured on the Netlify preview site.');
37
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ import { Command } from 'commander';
2
+ /**
3
+ * Build the fully-configured commander program. Kept separate from execution
4
+ * (see index.ts) so tests can assert on the command surface without spawning a
5
+ * process.
6
+ */
7
+ export declare function buildProgram(): Command;
package/dist/cli.js ADDED
@@ -0,0 +1,102 @@
1
+ import { Command } from 'commander';
2
+ import { readFileSync } from 'node:fs';
3
+ import { environmentCreate, environmentFinish } from './commands/environment.js';
4
+ import { init } from './commands/init.js';
5
+ import { migrationCreate, migrationRun } from './commands/migration.js';
6
+ import { setup } from './commands/setup.js';
7
+ import { CliError } from './errors.js';
8
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
9
+ /**
10
+ * Wrap a command action so that {@link CliError} is printed without a stack
11
+ * trace and sets a non-zero exit code, while unexpected errors propagate with
12
+ * their full stack.
13
+ */
14
+ function wrapAction(fn) {
15
+ return async (...args) => {
16
+ try {
17
+ await fn(...args);
18
+ }
19
+ catch (err) {
20
+ if (err instanceof CliError) {
21
+ process.stderr.write(`Error: ${err.message}\n`);
22
+ process.exitCode = 1;
23
+ }
24
+ else {
25
+ throw err;
26
+ }
27
+ }
28
+ };
29
+ }
30
+ /**
31
+ * Build the fully-configured commander program. Kept separate from execution
32
+ * (see index.ts) so tests can assert on the command surface without spawning a
33
+ * process.
34
+ */
35
+ export function buildProgram() {
36
+ const program = new Command();
37
+ program
38
+ .name('marketing')
39
+ .description(pkg.description ?? 'Sonar marketing CLI')
40
+ .version(pkg.version)
41
+ .option('--branch <name>', 'Override the detected branch name')
42
+ .option('--ci', 'Force CI mode')
43
+ .option('--no-ci', 'Force non-CI mode')
44
+ .option('--yes', 'Skip confirmation prompts');
45
+ // --- setup (real) ---
46
+ program
47
+ .command('setup')
48
+ .description('Assemble .env.* files from Netlify (headless).')
49
+ .action(wrapAction(async () => {
50
+ const globalOpts = program.opts();
51
+ await setup(globalOpts);
52
+ }));
53
+ // --- init (real) ---
54
+ const initCmd = program
55
+ .command('init')
56
+ .description('Migrate a repo from the legacy CLI to marketing.config.json.')
57
+ .option('--dry-run', 'Print proposed outputs without writing files')
58
+ .option('--force', 'Overwrite existing marketing.config.json');
59
+ initCmd.action(wrapAction(async () => {
60
+ const opts = initCmd.opts();
61
+ await init({ dryRun: opts.dryRun ?? false, force: opts.force ?? false });
62
+ }));
63
+ // --- migration (real) ---
64
+ const migration = program
65
+ .command('migration')
66
+ .description('Create and run Kontent.ai content-model migrations.');
67
+ migration
68
+ .command('create <name>')
69
+ .description('Scaffold a new timestamp-ordered TypeScript migration.')
70
+ .action(wrapAction(async (name) => {
71
+ await migrationCreate(name);
72
+ }));
73
+ migration
74
+ .command('run')
75
+ .description('Compile and run all pending migrations.')
76
+ .action(wrapAction(async () => {
77
+ const globalOpts = program.opts();
78
+ await migrationRun(globalOpts);
79
+ }));
80
+ // --- environment (real) ---
81
+ const environment = program
82
+ .command('environment')
83
+ .description('Create and finish per-branch Kontent.ai environments.');
84
+ const envCreate = environment
85
+ .command('create')
86
+ .description('Clone a Kontent.ai environment for the current branch.')
87
+ .option('--stack', 'Declare this branch as a long-lived branch in config')
88
+ .option('--source <branch>', 'Clone from a specific long-lived branch instead of production');
89
+ envCreate.action(wrapAction(async () => {
90
+ const globalOpts = program.opts();
91
+ const localOpts = envCreate.opts();
92
+ await environmentCreate({ ...globalOpts, stack: localOpts.stack, source: localOpts.source });
93
+ }));
94
+ environment
95
+ .command('finish')
96
+ .description('Migrate source, delete clone, and clean up.')
97
+ .action(wrapAction(async () => {
98
+ const globalOpts = program.opts();
99
+ await environmentFinish(globalOpts);
100
+ }));
101
+ return program;
102
+ }
@@ -0,0 +1,36 @@
1
+ import { type MarketingConfig } from '../config.js';
2
+ export interface EnvironmentCreateOptions {
3
+ branch?: string;
4
+ ci?: boolean;
5
+ yes?: boolean;
6
+ stack?: boolean;
7
+ source?: string;
8
+ }
9
+ export interface EnvironmentFinishOptions {
10
+ branch?: string;
11
+ ci?: boolean;
12
+ yes?: boolean;
13
+ }
14
+ interface ResolvedSource {
15
+ environmentId: string;
16
+ isProduction: boolean;
17
+ /** The long-lived branch key this was resolved from, if any. */
18
+ sourceBranch?: string;
19
+ }
20
+ /**
21
+ * Slugify a branch name for use as the Kontent clone name.
22
+ * Non-alphanumeric characters (except `-`) → `-`, consecutive `-` collapsed,
23
+ * leading/trailing `-` trimmed.
24
+ */
25
+ export declare function slugifyBranch(branch: string): string;
26
+ /**
27
+ * Source resolution — deterministic from the `longLivedBranches` config tree.
28
+ *
29
+ * 1. If branch is a child of a key → parent's `environmentId`
30
+ * 2. If branch is a key (not a child of another key) → production
31
+ * 3. Not in config at all → production
32
+ */
33
+ export declare function resolveSourceEnvironment(branch: string, config: MarketingConfig): ResolvedSource;
34
+ export declare function environmentCreate(opts: EnvironmentCreateOptions): Promise<void>;
35
+ export declare function environmentFinish(opts: EnvironmentFinishOptions): Promise<void>;
36
+ export {};