Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,27 @@ deno init --npm harper my-react-app --template react-ts

Currently supported template presets include:

- `vanilla`
- `vanilla-ts`
- `react`
- `react-ts`
- `nextjs`
- `nextjs-ts`
- `vanilla`, `vanilla-ts`
- `react`, `react-ts`, `react-ssr`, `react-ts-ssr`
- `vue`, `vue-ts`, `vue-ssr`, `vue-ts-ssr`
- `nextjs`, `nextjs-ts`

You can use `.` for the project name to scaffold in the current directory.

## Deployment

Every project comes with a GitHub Actions workflow that tests pull requests and deploys to your [Harper Fabric](https://fabric.harper.fast/) cluster when a change merges to `main`. It stores no Harper credential: the deploy job authenticates with GitHub's OIDC identity token, which the cluster accepts under a trust policy.

After you push the new project to GitHub, set that up once from the project, signed in to the cluster as a super user with `harper login` (Harper 5.4 or later):

```bash
npm run deploy:setup-ci
```

It creates a deploy-only user and the trust policy on the cluster, and sets the workflow's `HARPER_CLI_TARGET` repository variable. Each project's README covers the rest: going back to an earlier release, staging a release, and what to do when a run is refused.

Projects created before this deploy on version tags with a stored `HARPER_CLI_REFRESH_TOKEN`. To move one over, scaffold a new project with the same name, template and package manager, copy its `.github/workflows/deploy.yaml` over yours, run `harper deploy setup=true provider=github-actions project=<the workflow's project= value>`, push, and then delete the old secret.

## Auto-updates

`create-harper` will automatically check for newer versions on npm. If a newer version is available, it will automatically re-run the process using the latest version to ensure you are using the most up-to-date templates and features.
Expand Down
3 changes: 3 additions & 0 deletions lib/constants/helpMessage.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,8 @@ If you are going to deploy your application to https://fabric.harper.fast/, you
-c, --deploymentURL The https://fabric.harper.fast/ URL where you will host your application
For authentication, use "harper login" in your project directory.

Projects deploy from GitHub Actions when a change merges to main. After pushing the project to GitHub, run
its "deploy:setup-ci" script once to let the workflow deploy to your cluster (see the project's README).

Available templates:
${availableTemplates}`;
46 changes: 31 additions & 15 deletions lib/fs/copyFile.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,34 @@ const DEFAULT_TARGETS = new Set([defaultTarget, 'your-fabric.harper.fast-cluster
const TARGET_ASSIGNMENT = /^[ \t]*(?:export[ \t]+)?(?:HARPER_CLI_TARGET|CLI_TARGET)[ \t]*=/;

/**
* @param {string} existingEnv - The existing `.env` content.
* @param {string} line - One line of the template's `.env` content.
* @returns {boolean} Whether the line sets the target to a placeholder.
*/
function setsPlaceholderTarget(line) {
return TARGET_ASSIGNMENT.test(line) && Object.values(dotenv.parse(line)).some((value) => DEFAULT_TARGETS.has(value));
}

/**
* The CLI reads a target from `.env` ahead of the one `harper login` saves, so a placeholder there
* would send every command to a host that doesn't exist. With no cluster URL supplied, the target
* line is written commented out instead, and `harper login` adds the real one; when the existing
* `.env` already sets a target, the line is left out.
*
* @param {string} templateEnv - The template's `.env` content, after substitution.
* @returns {string} The template content to append. The last assignment wins, so a default
* target appended after a configured one would replace it.
* @param {string} [existingEnv] - The existing `.env` content, when the template is appended to it.
* @returns {string} The template content to write.
*/
function harperEnvToAppend(existingEnv, templateEnv) {
function withoutPlaceholderTarget(templateEnv, existingEnv = '') {
const existing = dotenv.parse(existingEnv);
const template = dotenv.parse(templateEnv);
const keepsConfiguredTarget = TARGET_KEYS.some((key) => existing[key])
&& TARGET_KEYS.some((key) => DEFAULT_TARGETS.has(template[key]));
if (!keepsConfiguredTarget) {
return templateEnv;
}
const hasTarget = TARGET_KEYS.some((key) => existing[key]);
return templateEnv
.split('\n')
.filter((line) => !TARGET_ASSIGNMENT.test(line))
.flatMap((line) => {
if (!setsPlaceholderTarget(line)) {
return [line];
}
return hasTarget ? [] : [`# ${line.trim()}`];
})
.join('\n');
}

Expand All @@ -42,13 +54,17 @@ function harperEnvToAppend(existingEnv, templateEnv) {
*/
export function copyFile(sourcePath, targetPath, substitutions) {
let updatedContent = applySubstitutions(fs.readFileSync(sourcePath, 'utf-8'), targetPath, substitutions);
if (targetPath.endsWith('.env') && fs.existsSync(targetPath)) {
const existingContent = fs.readFileSync(targetPath, 'utf-8');
updatedContent = `# Your .env file contents:
if (targetPath.endsWith('.env')) {
if (fs.existsSync(targetPath)) {
const existingContent = fs.readFileSync(targetPath, 'utf-8');
updatedContent = `# Your .env file contents:
${existingContent}

# Harper .env contents:
${harperEnvToAppend(existingContent, updatedContent)}`;
${withoutPlaceholderTarget(updatedContent, existingContent)}`;
} else {
updatedContent = withoutPlaceholderTarget(updatedContent);
}
}
writeFile(targetPath, updatedContent);

Expand Down
42 changes: 37 additions & 5 deletions lib/fs/copyFile.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,11 @@ NEW_VAR=value`;
});

expect(vi.mocked(fs.writeFileSync).mock.calls[0][1]).toContain(
"HARPER_CLI_TARGET='YOUR_FABRIC.HARPER.FAST_CLUSTER_URL_HERE'",
"# HARPER_CLI_TARGET='YOUR_FABRIC.HARPER.FAST_CLUSTER_URL_HERE'",
);
});

test('keeps the template target when the existing .env sets none', () => {
test('comments out a placeholder target when the existing .env sets none', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockImplementation((path) => {
if (path === 'template.env') { return "HARPER_CLI_TARGET='your-fabric.harper.fast-cluster-url-here'"; }
Expand All @@ -131,9 +131,41 @@ NEW_VAR=value`;

copyFile('template.env', '.env');

expect(vi.mocked(fs.writeFileSync).mock.calls[0][1]).toContain(
"HARPER_CLI_TARGET='your-fabric.harper.fast-cluster-url-here'",
);
const written = vi.mocked(fs.writeFileSync).mock.calls[0][1];
expect(written).toContain("# HARPER_CLI_TARGET='your-fabric.harper.fast-cluster-url-here'");
expect(dotenv.parse(written).HARPER_CLI_TARGET).toBeUndefined();
});

describe('in a new .env', () => {
const writeNewEnv = (template, substitutions) => {
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(fs.readFileSync).mockReturnValue(template);
copyFile('template.env', '.env', substitutions);
return vi.mocked(fs.writeFileSync).mock.calls[0][1];
};

test.each([
['the default target', "HARPER_CLI_TARGET='your-fabric.harper.fast-cluster-url-here'\n", {
'your-fabric.harper.fast-cluster-url-here': 'YOUR_FABRIC.HARPER.FAST_CLUSTER_URL_HERE',
}],
['the raw placeholder', "HARPER_CLI_TARGET='your-fabric.harper.fast-cluster-url-here'\n", undefined],
['a legacy CLI_TARGET', "CLI_TARGET='your-fabric.harper.fast-cluster-url-here'\n", undefined],
['an exported target', "export HARPER_CLI_TARGET='your-fabric.harper.fast-cluster-url-here'\n", undefined],
])('comments out %s, so harper login can set the real one', (_name, template, substitutions) => {
const written = writeNewEnv(template, substitutions);

expect(written).toMatch(/^# (?:export )?(?:HARPER_)?CLI_TARGET=/);
const env = dotenv.parse(written);
expect(env.HARPER_CLI_TARGET ?? env.CLI_TARGET).toBeUndefined();
});

test('writes a supplied cluster URL', () => {
const written = writeNewEnv("HARPER_CLI_TARGET='your-fabric.harper.fast-cluster-url-here'\nNEW_VAR=value\n", {
'your-fabric.harper.fast-cluster-url-here': 'https://new.example:9925/',
});

expect(written).toBe("HARPER_CLI_TARGET='https://new.example:9925/'\nNEW_VAR=value\n");
});
});

test('does not append if .env file does not exist', () => {
Expand Down
20 changes: 20 additions & 0 deletions lib/pkg/toComponentName.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* The name a project deploys under. Harper accepts only letters, numbers, dashes and underscores in a
* component name, and the name is substituted into shell commands in the scaffolded package scripts
* and workflow, so a project directory named `my app` or `a; b` must not reach them as typed.
*
* @param {string} projectName - The name of the directory the project lives in.
* @returns {string} - A component name made of `[A-Za-z0-9_-]`.
*/
export function toComponentName(projectName) {
const name = String(projectName ?? '').replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
if (!name) { return 'app'; }
// The harper CLI JSON-parses each `key=value` argument, so `2026` or `true` would arrive as a number
// or a boolean rather than a name.
try {
JSON.parse(name);
return `app-${name}`;
} catch {
return name;
}
}
27 changes: 27 additions & 0 deletions lib/pkg/toComponentName.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, test } from 'vitest';
import { toComponentName } from './toComponentName.js';

describe('toComponentName', () => {
test('keeps a name Harper already accepts', () => {
expect(toComponentName('my-app_2')).toBe('my-app_2');
});

test('replaces anything a shell or Harper would treat specially', () => {
expect(toComponentName('my app')).toBe('my-app');
expect(toComponentName('a; printf INJECTED')).toBe('a-printf-INJECTED');
expect(toComponentName('$(touch x)')).toBe('touch-x');
expect(toComponentName('@scope/pkg')).toBe('scope-pkg');
});

test('never yields a name the harper CLI would parse as a number or boolean', () => {
expect(toComponentName('2026')).toBe('app-2026');
expect(toComponentName('1e5')).toBe('app-1e5');
expect(toComponentName('true')).toBe('app-true');
expect(toComponentName('null')).toBe('app-null');
});

test('falls back when nothing usable is left', () => {
expect(toComponentName('...')).toBe('app');
expect(toComponentName('')).toBe('app');
});
});
15 changes: 15 additions & 0 deletions lib/steps/installAndOptionallyStart.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as prompts from '@clack/prompts';
import fs from 'node:fs';
import path from 'node:path';
import { install } from '../install.js';
import { getInstallCommand } from '../pkg/getInstallCommand.js';
Expand All @@ -20,6 +21,20 @@ export function installAndOptionallyStart(root, pkgManager, immediate, skipInsta
install(root, pkgManager, selectedSkills, selectedAgents);
}

// The scaffolder can't do this setup itself: the GitHub repository rarely exists yet.
if (fs.existsSync(path.join(root, '.github', 'workflows', 'deploy.yaml'))) {
prompts.note(
[
'1. Push this project to a GitHub repository.',
'2. Sign in to your cluster as a super user: harper login',
`3. ${getRunCommand(pkgManager, 'deploy:setup-ci').join(' ')}`,
'',
'Then merging to main deploys. See "Deployment" in README.md.',
].join('\n'),
'Deploy from GitHub',
);
}

if (immediate && !skipInstall) {
start(root, pkgManager);
} else {
Expand Down
4 changes: 4 additions & 0 deletions lib/steps/scaffoldProject.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { crawlTemplateDir } from '../fs/crawlTemplateDir.js';
import { getWorkflowSubstitutions } from '../pkg/getWorkflowSubstitutions.js';
import { toComponentName } from '../pkg/toComponentName.js';

/**
* Step 6: Create the project structure and files based on the collected information.
Expand All @@ -23,6 +24,9 @@ export function scaffoldProject(root, projectName, packageName, template, envVar
const agent = pkgManager || 'npm';
const substitutions = {
'your-project-name-here': projectName || 'your-project-name-here',
// From the directory the app lives in, as `harper deploy` derives a project name; the project
// name argument can be `.` or a path.
'your-component-name-here': toComponentName(path.basename(root)),
'your-package-name-here': packageName || 'your-package-name-here',
'your-fabric.harper.fast-cluster-url-here': envVars?.target || 'your-fabric.harper.fast-cluster-url-here',
// Substitution runs these keys in order as plain string replaces, so the longer
Expand Down
25 changes: 25 additions & 0 deletions lib/steps/showOutro.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import * as prompts from '@clack/prompts';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { install } from '../install.js';
import { start } from '../start.js';
Expand Down Expand Up @@ -48,4 +51,26 @@ describe('installAndOptionallyStart', () => {
installAndOptionallyStart('/cwd', 'npm', false);
expect(prompts.outro).not.toHaveBeenLastCalledWith(expect.stringContaining('cd '));
});

// The scaffolder can't set up deploys itself (the GitHub repository rarely exists yet), so it
// lists the steps whenever the project ships the deploy workflow — even when it starts dev.
test('lists the deploy setup steps when the project ships a deploy workflow', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'create-harper-outro-'));
try {
fs.mkdirSync(path.join(root, '.github', 'workflows'), { recursive: true });
fs.writeFileSync(path.join(root, '.github', 'workflows', 'deploy.yaml'), '');
installAndOptionallyStart(root, 'npm', true);
expect(prompts.note).toHaveBeenCalledWith(
expect.stringContaining('Push this project to a GitHub repository.'),
'Deploy from GitHub',
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});

test('says nothing about deploying when the project has no deploy workflow', () => {
installAndOptionallyStart('/root', 'npm', false);
expect(prompts.note).not.toHaveBeenCalled();
});
});
Loading
Loading