Skip to content

Commit a64e79a

Browse files
committed
build: update bin/env to make project installable
1 parent 47c481b commit a64e79a

25 files changed

+1034
-50
lines changed

.storybook/main.ts

+4
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ const config: StorybookConfig = {
1818
staticDirs: [{from: './assets', to: '/storybook-assets'}],
1919
core: {
2020
disableTelemetry: true
21+
},
22+
viteFinal(config) {
23+
config.server.allowedHosts = true;
24+
return config;
2125
}
2226
};
2327
export default config;

bin/_env/commands/coreCommands.ts

+12
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ const commands: CommandEntrypoint = async (program, context) => {
88
.action(() => {
99
dumpConfigToFile(context.getPaths());
1010
});
11+
12+
program
13+
.command('install')
14+
.description('Installs the project on your device; sets up a unique url, ip address, hosts entry and ssl certificate')
15+
.action(() => context.getInstaller().install());
16+
17+
// Create hook to ensure loopback IP is registered before docker:up
18+
context.getEvents().on('docker:up:before', async () => {
19+
if (context.getEnv().has('DOCKER_PROJECT_INSTALLED')) {
20+
await context.getInstaller().ensureLoopbackIp();
21+
}
22+
});
1123
};
1224

1325
export default commands;

bin/_env/commands/docker/DockerConfig.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ export class DockerConfig {
55
public readonly defaultServiceName: string = getConfigValue('defaultServiceName', 'app', 'SERVICE_NAME');
66
public readonly defaultUid: string = getConfigValue('dockerUId', '1000', 'ENV_UID');
77
public readonly defaultGid: string = getConfigValue('dockerGid', '1000', 'ENV_GID');
8+
public readonly projectDomainSuffix: string = getConfigValue('projectDomainSuffix', '.dev.local');
89
public readonly projectName: string = getEnvValue('PROJECT_NAME');
910
public readonly projectProtocol: string = getEnvValue('DOCKER_PROJECT_PROTOCOL', 'http');
1011
public readonly projectDomain: string = getEnvValue('DOCKER_PROJECT_DOMAIN', 'localhost');
1112
public readonly projectIp: string = getEnvValue('DOCKER_PROJECT_IP', '127.0.0.1');
1213
public readonly projectPort: string = getEnvValue('DOCKER_PROJECT_PORT', '80');
13-
public readonly shellsToUse: string[] = getEnvValue('SHELLS_TO_USE', 'bash,sh,zsh,dash,ksh').split(',').map((shell) => shell.trim());
14+
public readonly shellsToUse: string[] = getConfigValue('shellList', 'bash,sh,zsh,dash,ksh', 'SHELLS_TO_USE').split(',').map((shell: string) => shell.trim());
1415

1516
public get projectHost(): string {
1617
const expectedPort = this.projectProtocol === 'http' ? '80' : '443';

bin/_env/commands/docker/DockerContext.ts

+1
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ export class DockerContext {
317317
}
318318
args.add('--remove-orphans');
319319

320+
await this._context.getEvents().trigger('docker:up:before', {args});
320321
await this.executeComposeCommand(['up', ...args]);
321322
}
322323

bin/_env/commands/projectCommands.ts

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import type {CommandEntrypoint} from '/types.ts';
2+
3+
const commands: CommandEntrypoint = async (program, context) => {
4+
program
5+
.command('build')
6+
.description('Builds the library into the "dist" folder')
7+
.action(async () => {
8+
await context.docker.executeCommandInService('app', ['npm', 'run', 'build'], undefined, true);
9+
});
10+
11+
// When installing, ensure that we are running the correct port on the host machine
12+
context.getEvents().on('installer:envFile:filter', async ({envFile}) => {
13+
envFile.set('DOCKER_PROJECT_PORT', '443');
14+
});
15+
};
16+
17+
export default commands;

bin/_env/core/Context.ts

+35-2
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,40 @@ import {type Command} from 'commander';
22
import type {Paths} from './Paths.ts';
33
import type {EnvPackageInfo} from './types.ts';
44
import type {Config} from './Config.js';
5+
import type {Platform} from './Platform.ts';
6+
import type {EnvFile} from './EnvFile.ts';
7+
import {Installer} from './installer/Installer.ts';
8+
import type {EventBus} from './EventBus.ts';
59

610
export class Context {
711
private readonly _pkg: EnvPackageInfo;
12+
private readonly _env: EnvFile;
813
private readonly _paths: Paths;
914
private readonly _program: Command;
1015
private readonly _flags: WritableFlags;
11-
private _config: Config;
16+
private readonly _config: Config;
17+
private readonly _platform: Platform;
18+
private readonly _events: EventBus;
19+
private _installer?: Installer;
1220

1321
public constructor(
1422
pkg: EnvPackageInfo,
23+
env: EnvFile,
1524
paths: Paths,
1625
program: Command,
1726
flags: WritableFlags,
18-
config: Config
27+
config: Config,
28+
platform: Platform,
29+
events: EventBus
1930
) {
2031
this._pkg = pkg;
32+
this._env = env;
2133
this._paths = paths;
2234
this._program = program;
2335
this._flags = flags;
2436
this._config = config;
37+
this._platform = platform;
38+
this._events = events;
2539
}
2640

2741
public get flags(): ReadOnlyFlags {
@@ -32,6 +46,10 @@ export class Context {
3246
return this._pkg;
3347
}
3448

49+
public getEnv(): EnvFile {
50+
return this._env;
51+
}
52+
3553
public getPaths(): Paths {
3654
return this._paths;
3755
}
@@ -44,6 +62,21 @@ export class Context {
4462
return this._config;
4563
}
4664

65+
public getPlatform(): Platform {
66+
return this._platform;
67+
}
68+
69+
public getInstaller(): Installer {
70+
if (!this._installer) {
71+
this._installer = new Installer(this);
72+
}
73+
return this._installer;
74+
}
75+
76+
public getEvents(): EventBus {
77+
return this._events;
78+
}
79+
4780
public registerAddon(key: string, addon: object) {
4881
Object.defineProperty(this, key, {
4982
get: () => addon

bin/_env/core/EnvFile.ts

+108-35
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,38 @@
11
import type {Paths} from './Paths.js';
22
import * as fs from 'node:fs';
33
import {confirm, input} from '@inquirer/prompts';
4-
import {parse as parseEnv} from 'dotenv';
54
import * as path from 'node:path';
65

76
let loadedEnvFile: EnvFile | undefined = undefined;
87

8+
interface EnvFileState {
9+
filename: string;
10+
values: Map<string, string>;
11+
tpl: string;
12+
}
13+
914
export class EnvFile {
10-
private readonly _values: Map<string, string>;
15+
private readonly _state: EnvFileState;
1116

12-
public constructor(values: Map<string, string>) {
13-
this._values = values;
17+
public constructor(state: EnvFileState) {
18+
this._state = state;
1419
}
1520

1621
public get(key: string, fallback?: string): string | undefined {
17-
return this._values.get(key) || fallback;
22+
return this._state.values.get(key) || fallback;
1823
}
1924

2025
public has(key: string): boolean {
21-
return this._values.has(key);
26+
return this._state.values.has(key);
27+
}
28+
29+
public set(key: string, value: string): this {
30+
this._state.values.set(key, value);
31+
return this;
32+
}
33+
34+
public write(): void {
35+
writeStateToFile(this._state);
2236
}
2337
}
2438

@@ -37,54 +51,113 @@ export async function makeEnvFile(paths: Paths): Promise<EnvFile> {
3751
fs.copyFileSync(paths.envFileTemplatePath, paths.envFilePath);
3852
}
3953

40-
return loadedEnvFile = await ensureEnvFileContainsProjectName(loadEnvFile(paths), paths);
54+
const envFile = new EnvFile(loadEnvFileState(paths.envFilePath));
55+
56+
if (!envFile.has('PROJECT_NAME') || envFile.get('PROJECT_NAME') === '' || envFile.get('PROJECT_NAME') === 'replace-me') {
57+
const projectName = await input({
58+
message: 'You need to define a project name, which can be used for your docker containers and generated urls. Please enter a project name:',
59+
validate: (input) => {
60+
return input.length > 0 && input.match(/^[a-z0-9-]+$/) ? true : 'The project name must only contain lowercase letters, numbers and dashes';
61+
},
62+
default: extractProjectNameFromPath(paths),
63+
required: true
64+
});
65+
66+
envFile.set('PROJECT_NAME', projectName);
67+
envFile.write();
68+
}
69+
70+
return loadedEnvFile = envFile;
4171
}
4272

4373
export function getEnvValue(key: string, fallback?: string): string {
4474
if (loadedEnvFile && loadedEnvFile.has(key)) {
4575
return loadedEnvFile.get(key)!;
76+
} else if (process.env[key]) {
77+
return process.env[key]!;
4678
} else if (fallback) {
4779
return fallback;
4880
} else {
81+
console.log(loadedEnvFile, key);
4982
throw new Error(`Missing required env value: ${key}`);
5083
}
5184
}
5285

53-
function loadEnvFileContent(paths: Paths): string {
54-
if (!fs.existsSync(paths.envFilePath)) {
55-
throw new Error(`Env file does not exist: ${paths.envFilePath}`);
56-
}
57-
58-
return fs.readFileSync(paths.envFilePath).toString('utf-8');
86+
function loadEnvFileState(filename: string): EnvFileState {
87+
return {
88+
filename,
89+
...parseFile(fs.readFileSync(filename).toString('utf-8'))
90+
};
5991
}
6092

61-
function loadEnvFile(paths: Paths): EnvFile {
62-
return new EnvFile(new Map(Object.entries(parseEnv(loadEnvFileContent(paths)))));
63-
}
93+
function parseFile(content: string): {
94+
values: Map<string, string>;
95+
tpl: string;
96+
} {
97+
const lines = content.split(/\r?\n/);
98+
const tpl: Array<string> = [];
99+
const values = new Map();
100+
101+
// Iterate the lines
102+
lines.forEach(line => {
103+
let _line = line.trim();
104+
105+
// Skip comments and empty lines
106+
if (_line.length === 0 || _line.charAt(0) === '#' || _line.indexOf('=') === -1) {
107+
tpl.push(line);
108+
return;
109+
}
64110

65-
async function ensureEnvFileContainsProjectName(envFile: EnvFile, paths: Paths) {
66-
if (!envFile.has('PROJECT_NAME') || envFile.get('PROJECT_NAME') === '' || envFile.get('PROJECT_NAME') === 'replace-me') {
67-
const projectName = await input({
68-
message: 'You need to define a project name, which can be used for your docker containers and generated urls. Please enter a project name:',
69-
validate: (input) => {
70-
return input.length > 0 && input.match(/^[a-z0-9-]+$/) ? true : 'The project name must only contain lowercase letters, numbers and dashes';
71-
},
72-
default: extractProjectNameFromPath(paths),
73-
required: true
74-
});
111+
// Extract key value and store the line in the template
112+
tpl.push(_line.replace(/^([^=]*?)(?:\s+)?=(?:\s+)?(.*?)(\s#|$)/, (_, key, value, comment) => {
113+
// Prepare value
114+
value = value.trim();
115+
if (value.length === 0) {
116+
value = null;
117+
}
118+
119+
// Handle comment only value
120+
if (typeof value === 'string' && value.charAt(0) === '#') {
121+
comment = ' ' + value;
122+
value = null;
123+
}
124+
125+
key = key.trim();
126+
if (values.has(key)) {
127+
throw new Error('Invalid .env file! There was a duplicate key: ' + key);
128+
}
129+
values.set(key.trim(), value);
130+
return '{{pair}}' + ((comment + '').trim().length > 0 ? comment : '');
131+
}));
132+
});
133+
134+
return {
135+
values: values,
136+
tpl: tpl.join('\n')
137+
};
138+
}
75139

76-
let envContent = loadEnvFileContent(paths);
77-
if (envContent.includes('PROJECT_NAME=replace-me')) {
78-
envContent = envContent.replace('PROJECT_NAME=replace-me', `PROJECT_NAME=${projectName}`);
79-
} else {
80-
envContent += (envContent.length > 0 ? `\n` : '') + `PROJECT_NAME=${projectName}`;
140+
function writeStateToFile(state: EnvFileState): void {
141+
// Build the content based on the template and the current storage
142+
const keys: Array<string> = Array.from(state.values.keys());
143+
let contents = state.tpl.replace(/{{pair}}/g, () => {
144+
const key = keys.shift();
145+
const value = state.values.get(key + '');
146+
return key + '=' + value;
147+
});
148+
149+
if (keys.length > 0) {
150+
for (const key of keys) {
151+
const value = state.values.get(key);
152+
contents += '\n' + key + '=' + value;
81153
}
82-
83-
fs.writeFileSync(paths.envFilePath, envContent);
84-
return loadEnvFile(paths);
85154
}
86155

87-
return envFile;
156+
// Remove all spacing at the top and bottom of the file
157+
contents = contents.replace(/^\s+|\s+$/g, '');
158+
159+
// Write the file
160+
fs.writeFileSync(state.filename, contents);
88161
}
89162

90163
function extractProjectNameFromPath(paths: Paths): string {

bin/_env/core/EventBus.ts

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type {ConcreteInstaller} from './installer/concrete/types.ts';
2+
import type {EnvFile} from './EnvFile.ts';
3+
4+
interface EventTypes {
5+
'docker:up:before': { args: Set<string> };
6+
'installer:before': { installer: ConcreteInstaller };
7+
'installer:dependencies:before': undefined;
8+
'installer:loopbackIp:before': { ip: string };
9+
'installer:domain:before': { domain: string, ip: string };
10+
'installer:certificates:before': undefined;
11+
'installer:envFile:filter': { envFile: EnvFile };
12+
'installer:after': undefined;
13+
}
14+
15+
export class EventBus {
16+
private readonly _events: Map<keyof EventTypes, Set<(arg: EventTypes[keyof EventTypes]) => Promise<void>>> = new Map();
17+
18+
public async trigger<E extends keyof EventTypes>(event: E, arg: EventTypes[E] = undefined): Promise<EventTypes[E]> {
19+
const callbacks = this._events.get(event);
20+
if (callbacks) {
21+
for (const callback of callbacks) {
22+
await callback(arg);
23+
}
24+
}
25+
return arg;
26+
}
27+
28+
public on<E extends keyof EventTypes>(event: E, callback: (arg: EventTypes[E]) => Promise<void>): this {
29+
if (!this._events.has(event)) {
30+
this._events.set(event, new Set());
31+
}
32+
this._events.get(event)!.add(callback);
33+
return this;
34+
}
35+
}
36+
37+
38+
const b = new EventBus();

0 commit comments

Comments
 (0)