@abcnews/aunty 17.0.0-next.2 → 17.0.0-next.4
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/.prettierrc +1 -0
- package/.vscode/settings.json +5 -0
- package/CONTRIBUTING.md +55 -0
- package/assets/done.mp3 +0 -0
- package/eslint.config.mjs +7 -0
- package/package.json +3 -4
- package/scripts/postinstall.ts +7 -0
- package/src/bin/aunty.ts +1 -1
- package/src/commands/deploy/fs.ts +32 -0
- package/src/commands/deploy/ftp.ts +141 -0
- package/src/commands/deploy/index.ts +147 -0
- package/src/commands/release-check/git.ts +51 -0
- package/src/commands/release-check/index.ts +61 -0
- package/src/lib/constants.ts +1 -0
- package/src/lib/terminal.ts +54 -0
- package/src/lib/util.ts +37 -0
- package/tsconfig.json +17 -0
package/.prettierrc
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
## Contributing
|
|
2
|
+
|
|
3
|
+
To contribute to the development of **aunty**, clone the project:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
git clone git@github.com:abcnews/aunty.git
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
...then, from the project directory, run:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm link
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
This will link the globally-available `aunty` command to your clone.
|
|
16
|
+
|
|
17
|
+
To revert to your original global install, run:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm unlink
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Releasing new versions of `@abcnews/aunty`
|
|
24
|
+
|
|
25
|
+
Releases are managed by `release-it`. To release a new version of aunty from the default branch, run:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
npm run release
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
By default this will do the following:
|
|
32
|
+
|
|
33
|
+
1. Bump the `patch` version in `package.json` (and `package-lock.json` if it exists)
|
|
34
|
+
2. Commit and tag that version.
|
|
35
|
+
3. Push the tag & commit to GitHub
|
|
36
|
+
4. Publish to npm
|
|
37
|
+
|
|
38
|
+
If you want to cut a minor or major release, run either of the following commands instead:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
npm run release -- minor
|
|
42
|
+
npm run release -- major
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
If you're ever unsure about what will happen, you can perform a dry run (which logs to the console) by running:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
npm run release -- --dry-run
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
View the [`release-it` docs](https://www.npmjs.com/package/release-it) for full usage examples, including pre-release and npm tag management.
|
|
52
|
+
|
|
53
|
+
## Style
|
|
54
|
+
|
|
55
|
+
This project's codebase should be managed with [eslint](https://github.com/eslint/eslint) and [prettier](https://github.com/prettier/prettier). You should configure your editor to take advantage of this to maintain the code style specifed in `.eslintrc` and `.prettierrc`. If your editor has a format-on-save option and a Prettier plugin, even better!
|
package/assets/done.mp3
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@abcnews/aunty",
|
|
3
|
-
"version": "17.0.0-next.
|
|
3
|
+
"version": "17.0.0-next.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "ABC News Digital developer toolkit",
|
|
6
6
|
"repository": {
|
|
@@ -22,9 +22,6 @@
|
|
|
22
22
|
"clean": "rimraf dist",
|
|
23
23
|
"prebuild": "npm run clean"
|
|
24
24
|
},
|
|
25
|
-
"files": [
|
|
26
|
-
"dist"
|
|
27
|
-
],
|
|
28
25
|
"publishConfig": {
|
|
29
26
|
"access": "public"
|
|
30
27
|
},
|
|
@@ -50,12 +47,14 @@
|
|
|
50
47
|
"typescript-eslint": "^8.54.0"
|
|
51
48
|
},
|
|
52
49
|
"dependencies": {
|
|
50
|
+
"@clack/prompts": "^1.2.0",
|
|
53
51
|
"async": "^3.2.6",
|
|
54
52
|
"basic-ftp": "^5.2.0",
|
|
55
53
|
"commander": "^14.0.3",
|
|
56
54
|
"ora": "^9.3.0",
|
|
57
55
|
"picocolors": "^1.1.1",
|
|
58
56
|
"slugify": "^1.6.8",
|
|
57
|
+
"tsx": "^4.21.0",
|
|
59
58
|
"zx": "^8.8.5"
|
|
60
59
|
}
|
|
61
60
|
}
|
package/src/bin/aunty.ts
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Recursively gets a list of all files in a directory.
|
|
6
|
+
* @param dirPath Path to the directory
|
|
7
|
+
* @param baseDir Optional base directory to calculate relative paths from
|
|
8
|
+
* @returns Array of file info objects
|
|
9
|
+
*/
|
|
10
|
+
export async function getFileInventory(
|
|
11
|
+
dirPath: string,
|
|
12
|
+
baseDir: string = dirPath,
|
|
13
|
+
): Promise<{ path: string; relPath: string; size: number }[]> {
|
|
14
|
+
const inventory: { path: string; relPath: string; size: number }[] = [];
|
|
15
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
16
|
+
|
|
17
|
+
for (const entry of entries) {
|
|
18
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
19
|
+
if (entry.isDirectory()) {
|
|
20
|
+
inventory.push(...(await getFileInventory(fullPath, baseDir)));
|
|
21
|
+
} else if (entry.isFile()) {
|
|
22
|
+
const stats = await fs.stat(fullPath);
|
|
23
|
+
inventory.push({
|
|
24
|
+
path: fullPath,
|
|
25
|
+
relPath: path.relative(baseDir, fullPath),
|
|
26
|
+
size: stats.size,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return inventory;
|
|
32
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import ftp from "basic-ftp";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { loadJson } from "../../lib/util.ts";
|
|
5
|
+
import { spin } from "../../lib/terminal.ts";
|
|
6
|
+
|
|
7
|
+
const CREDENTIALS_PATH = path.resolve(os.homedir(), ".abc-credentials");
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Loads credentials from ~/.abc-credentials
|
|
11
|
+
*/
|
|
12
|
+
async function getCredentials(): Promise<{
|
|
13
|
+
host: string;
|
|
14
|
+
username?: string;
|
|
15
|
+
password?: string;
|
|
16
|
+
port?: number;
|
|
17
|
+
}> {
|
|
18
|
+
const credentials = (await loadJson(CREDENTIALS_PATH)) as {
|
|
19
|
+
contentftp?: {
|
|
20
|
+
host: string;
|
|
21
|
+
username?: string;
|
|
22
|
+
password?: string;
|
|
23
|
+
port?: number;
|
|
24
|
+
};
|
|
25
|
+
} | null;
|
|
26
|
+
const contentftp = credentials?.contentftp;
|
|
27
|
+
if (!contentftp) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`Credentials file not found or missing 'contentftp' at ${CREDENTIALS_PATH}`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return contentftp;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A wrapper around basic-ftp to provide a cleaner interface for ABC news-projects deployments.
|
|
37
|
+
*/
|
|
38
|
+
export class FtpClient {
|
|
39
|
+
private ftpClient: ftp.Client;
|
|
40
|
+
private ensuredDirs: Set<string>;
|
|
41
|
+
private credentialsPromise: ReturnType<typeof getCredentials>;
|
|
42
|
+
|
|
43
|
+
constructor(verbose = false) {
|
|
44
|
+
this.ftpClient = new ftp.Client();
|
|
45
|
+
this.ensuredDirs = new Set();
|
|
46
|
+
this.credentialsPromise = getCredentials();
|
|
47
|
+
if (verbose) {
|
|
48
|
+
this.ftpClient.ftp.verbose = true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Connect to the FTP server
|
|
54
|
+
*/
|
|
55
|
+
async connect(timeout = 5000) {
|
|
56
|
+
// @ts-expect-error - basic-ftp context timeout is readonly but allows runtime assignment
|
|
57
|
+
this.ftpClient.ftp.timeout = timeout;
|
|
58
|
+
const credentials = await this.credentialsPromise;
|
|
59
|
+
await this.ftpClient.access({
|
|
60
|
+
host: credentials.host,
|
|
61
|
+
user: credentials.username,
|
|
62
|
+
password: credentials.password,
|
|
63
|
+
port: Number(credentials.port) || 21,
|
|
64
|
+
secure: false,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check if a directory exists on the remote
|
|
70
|
+
*/
|
|
71
|
+
async exists(remotePath: string) {
|
|
72
|
+
try {
|
|
73
|
+
const parent = path.dirname(remotePath);
|
|
74
|
+
const name = path.basename(remotePath);
|
|
75
|
+
const list = await this.ftpClient.list(parent);
|
|
76
|
+
return list.some((item) => item.name === name);
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Upload a directory to a remote path
|
|
84
|
+
*/
|
|
85
|
+
async uploadDir(
|
|
86
|
+
localDir: string,
|
|
87
|
+
remoteDir: string,
|
|
88
|
+
onProgress?: (info: {
|
|
89
|
+
name: string;
|
|
90
|
+
bytes: number;
|
|
91
|
+
bytesOverall: number;
|
|
92
|
+
}) => void,
|
|
93
|
+
) {
|
|
94
|
+
if (onProgress) {
|
|
95
|
+
this.ftpClient.trackProgress(onProgress);
|
|
96
|
+
}
|
|
97
|
+
await this.ftpClient.ensureDir(remoteDir);
|
|
98
|
+
await this.ftpClient.uploadFromDir(localDir);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Ensure a remote directory exists (with local caching)
|
|
103
|
+
*/
|
|
104
|
+
async ensureDir(remoteDir: string) {
|
|
105
|
+
if (this.ensuredDirs.has(remoteDir)) return;
|
|
106
|
+
|
|
107
|
+
await this.ftpClient.ensureDir(remoteDir);
|
|
108
|
+
this.ensuredDirs.add(remoteDir);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Close the connection
|
|
113
|
+
*/
|
|
114
|
+
close() {
|
|
115
|
+
this.ftpClient.close();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Test the FTP connection.
|
|
120
|
+
*/
|
|
121
|
+
async testConnection(
|
|
122
|
+
timeout = 5000,
|
|
123
|
+
spinner?: ReturnType<typeof spin>,
|
|
124
|
+
): Promise<FtpClient> {
|
|
125
|
+
const s = spinner || spin("Testing credentials...");
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
await this.connect(timeout);
|
|
129
|
+
if (spinner) {
|
|
130
|
+
s.message("Credentials verified");
|
|
131
|
+
} else {
|
|
132
|
+
s.stop("Credentials verified");
|
|
133
|
+
}
|
|
134
|
+
return this;
|
|
135
|
+
} catch (error: unknown) {
|
|
136
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
137
|
+
s.cancel(`Connection failed: ${message}.`);
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { intro, outro, confirm, log, cancel } from "@clack/prompts";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
import { FtpClient } from "./ftp.ts";
|
|
5
|
+
import { loadJson, formatSize } from "../../lib/util.ts";
|
|
6
|
+
import { getHeader, spin } from "../../lib/terminal.ts";
|
|
7
|
+
import { getFileInventory } from "./fs.ts";
|
|
8
|
+
import { BUILD_DIRECTORY_NAME } from "../../lib/constants.ts";
|
|
9
|
+
import slugify from "slugify";
|
|
10
|
+
|
|
11
|
+
interface DeployOptions {
|
|
12
|
+
destDir?: string;
|
|
13
|
+
buildDir?: string;
|
|
14
|
+
dryRun?: boolean;
|
|
15
|
+
force?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The main entry point for the 'aunty deploy' command.
|
|
20
|
+
*/
|
|
21
|
+
export async function run(options: DeployOptions = {}): Promise<number> {
|
|
22
|
+
const projectRoot = process.cwd();
|
|
23
|
+
|
|
24
|
+
intro(
|
|
25
|
+
getHeader(
|
|
26
|
+
pc.dim("aunty"),
|
|
27
|
+
`deploy${options.dryRun ? ` ${pc.cyan("[dry]")}` : ""}`,
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
// 1. Load config
|
|
32
|
+
const config = (await loadJson(path.join(projectRoot, "package.json"))) as {
|
|
33
|
+
name: string;
|
|
34
|
+
version: string;
|
|
35
|
+
} | null;
|
|
36
|
+
|
|
37
|
+
if (!config) {
|
|
38
|
+
log.error(`package.json not found in ${projectRoot}`);
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const { name, version } = config;
|
|
43
|
+
if (!name || !version) {
|
|
44
|
+
cancel("Missing name or version in package.json");
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 3. Construct target path
|
|
49
|
+
const localDir = path.resolve(
|
|
50
|
+
projectRoot,
|
|
51
|
+
options.buildDir || BUILD_DIRECTORY_NAME,
|
|
52
|
+
);
|
|
53
|
+
const nameSlug = (slugify as any)(name, { strict: true });
|
|
54
|
+
const targetFolder = options.destDir || version;
|
|
55
|
+
const remoteDir = `/www/res/sites/news-projects/${nameSlug}/${targetFolder}/`;
|
|
56
|
+
const publicUrl = `https://www.abc.net.au/res/sites/news-projects/${nameSlug}/${targetFolder}/`;
|
|
57
|
+
log.info(`${pc.bold("Remote dir:")} ${pc.dim(remoteDir)}`);
|
|
58
|
+
|
|
59
|
+
// 4. File Inventory & Size Check
|
|
60
|
+
let inventory;
|
|
61
|
+
try {
|
|
62
|
+
inventory = await getFileInventory(localDir);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
cancel(
|
|
65
|
+
`Build directory not found at ${pc.cyan(localDir)}. Have you run the build command?`,
|
|
66
|
+
);
|
|
67
|
+
return 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (inventory.length === 0) {
|
|
71
|
+
cancel(`Build directory is empty! Nothing to deploy.`);
|
|
72
|
+
return 1;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const list = inventory
|
|
76
|
+
.map((f) => ` ${pc.dim(f.relPath)} (${formatSize(f.size)})`)
|
|
77
|
+
.join("\n");
|
|
78
|
+
log.step(`Found ${pc.bold(inventory.length)} files to deploy:\n${list}`);
|
|
79
|
+
|
|
80
|
+
if (options.dryRun) {
|
|
81
|
+
outro(pc.green("Dry run complete. No files were uploaded."));
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 5. Credential Test & Confirmation
|
|
86
|
+
const ftpClient = new FtpClient();
|
|
87
|
+
try {
|
|
88
|
+
await ftpClient.testConnection();
|
|
89
|
+
} catch (err) {
|
|
90
|
+
// FtpClient.testConnection() already handles UI feedback via its own spinner
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const exists = await ftpClient.exists(remoteDir);
|
|
95
|
+
|
|
96
|
+
if (exists && !options.force) {
|
|
97
|
+
// Close the connection before waiting on user input to avoid a socket timeout
|
|
98
|
+
ftpClient.close();
|
|
99
|
+
|
|
100
|
+
const shouldOverwrite = await confirm({
|
|
101
|
+
message: `${pc.red(`Directory ${pc.bold(remoteDir)} already exists.`)} Overwrite?`,
|
|
102
|
+
initialValue: false,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (!shouldOverwrite || typeof shouldOverwrite === "symbol") {
|
|
106
|
+
cancel("Deploy cancelled.");
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Reconnect after the prompt
|
|
111
|
+
await ftpClient.connect();
|
|
112
|
+
} else if (!exists) {
|
|
113
|
+
await ftpClient.ensureDir(remoteDir);
|
|
114
|
+
} else if (options.force) {
|
|
115
|
+
log.info(pc.yellow("Force flag used. Overwriting remote directory."));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const uploadSpinner = spin("Uploading files...");
|
|
119
|
+
|
|
120
|
+
let uploadedCount = 0;
|
|
121
|
+
let currentFile = "";
|
|
122
|
+
const totalFilesStr = inventory.length.toString();
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
await ftpClient.uploadDir(localDir, remoteDir, (info) => {
|
|
126
|
+
if (info.name !== currentFile) {
|
|
127
|
+
uploadedCount++;
|
|
128
|
+
currentFile = info.name;
|
|
129
|
+
const countStr = uploadedCount
|
|
130
|
+
.toString()
|
|
131
|
+
.padStart(totalFilesStr.length, " ");
|
|
132
|
+
uploadSpinner.message(`${countStr}/${totalFilesStr} ${info.name}`);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
uploadSpinner.stop("Upload complete");
|
|
136
|
+
} catch (err) {
|
|
137
|
+
uploadSpinner.cancel("Upload failed");
|
|
138
|
+
ftpClient.close();
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
ftpClient.close();
|
|
143
|
+
|
|
144
|
+
log.info(`${pc.bold("Public URL:")} ${pc.cyan(publicUrl)}`);
|
|
145
|
+
outro(pc.green("Deploy complete!"));
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { $ } from "zx";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Checks if git is accessible and functional. Important on macOS where git
|
|
5
|
+
* may be paywalled by the Xcode license agreement.
|
|
6
|
+
*/
|
|
7
|
+
export async function isAccessible(): Promise<boolean> {
|
|
8
|
+
try {
|
|
9
|
+
await $`git --version`.quiet();
|
|
10
|
+
return true;
|
|
11
|
+
} catch (err) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Checks if the working directory is clean.
|
|
18
|
+
*/
|
|
19
|
+
export async function isClean(): Promise<boolean> {
|
|
20
|
+
const result = await $`git status --porcelain`.quiet();
|
|
21
|
+
return result.stdout.trim() === "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Gets the current branch name.
|
|
26
|
+
*/
|
|
27
|
+
export async function getBranch(): Promise<string> {
|
|
28
|
+
const result = await $`git branch --show-current`.quiet();
|
|
29
|
+
return result.stdout.trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Checks if the current branch is behind its remote tracking branch.
|
|
34
|
+
*/
|
|
35
|
+
export async function isBehindRemote(): Promise<boolean> {
|
|
36
|
+
await $`git fetch`.quiet();
|
|
37
|
+
const result = await $`git rev-list --count HEAD..@{u}`.quiet();
|
|
38
|
+
return Number(result.stdout.trim()) > 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Checks if the current branch has a remote tracking branch.
|
|
43
|
+
*/
|
|
44
|
+
export async function hasRemote(): Promise<boolean> {
|
|
45
|
+
try {
|
|
46
|
+
await $`git rev-parse --abbrev-ref @{u}`.quiet();
|
|
47
|
+
return true;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { intro, outro } from "@clack/prompts";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { getHeader, spin } from "../../lib/terminal.ts";
|
|
4
|
+
import { FtpClient } from "../deploy/ftp.ts";
|
|
5
|
+
import * as git from "./git.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Release checks that must pass before running an `aunty release`.
|
|
9
|
+
*/
|
|
10
|
+
export async function run(): Promise<number> {
|
|
11
|
+
intro(getHeader(pc.dim("aunty"), "release-check"));
|
|
12
|
+
|
|
13
|
+
const s = spin("Performing pre-release checks...");
|
|
14
|
+
|
|
15
|
+
// 1. Git Prerelease Checks
|
|
16
|
+
|
|
17
|
+
// 1.1 Check git accessibility.
|
|
18
|
+
if (!(await git.isAccessible())) {
|
|
19
|
+
s.cancel(
|
|
20
|
+
"Git is not accessible. Please ensure git is installed and any pending licenses (e.g. Xcode) are accepted.",
|
|
21
|
+
);
|
|
22
|
+
return 1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 1.2 Check for uncommitted changes
|
|
26
|
+
if (!(await git.isClean())) {
|
|
27
|
+
s.cancel("You have uncommitted changes.");
|
|
28
|
+
return 1;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 1.3 Check branch
|
|
32
|
+
const branch = await git.getBranch();
|
|
33
|
+
if (branch !== "main") {
|
|
34
|
+
s.cancel(
|
|
35
|
+
`You are on the ${pc.bold(branch)} branch. Releases must be from ${pc.bold("main")}.`,
|
|
36
|
+
);
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 1.4 Check remote sync
|
|
41
|
+
if (await git.hasRemote()) {
|
|
42
|
+
if (await git.isBehindRemote()) {
|
|
43
|
+
s.cancel("Your local branch is behind the remote.");
|
|
44
|
+
return 1;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 2. Check for FTP credentials
|
|
49
|
+
s.message("Testing FTP connection...");
|
|
50
|
+
try {
|
|
51
|
+
const ftpClient = await new FtpClient().testConnection(5000, s);
|
|
52
|
+
ftpClient.close();
|
|
53
|
+
} catch {
|
|
54
|
+
// Spinner cancellation is handled by testConnection
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
s.stop("Pre-release checks passed");
|
|
59
|
+
outro(pc.green("Ready for release!"));
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const BUILD_DIRECTORY_NAME = "dist";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { spinner } from "@clack/prompts";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
|
|
4
|
+
/** Get the ABC logo in ascii form */
|
|
5
|
+
export const getLogo = () => `
|
|
6
|
+
⣾${pc.dim("⢷")}⡾⢷${pc.dim("⡾")}⣷
|
|
7
|
+
⢿⡾${pc.dim("⢷⡾")}⢷⡿ `;
|
|
8
|
+
|
|
9
|
+
/** Get the ABC logo with optional text on each line */
|
|
10
|
+
export const getHeader = (line1: string = "", line2: string = "") => {
|
|
11
|
+
const logoLine1 = `⣾${pc.dim("⢷")}⡾⢷${pc.dim("⡾")}⣷`;
|
|
12
|
+
const logoLine2 = `⢿⡾${pc.dim("⢷⡾")}⢷⡿`;
|
|
13
|
+
|
|
14
|
+
return [
|
|
15
|
+
"",
|
|
16
|
+
`${pc.gray("│")} ${logoLine1} ${line1}`,
|
|
17
|
+
`${pc.gray("│")} ${logoLine2} ${pc.bold(line2)}`,
|
|
18
|
+
].join("\n");
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Create an ABC loading spinner using clack's spinner */
|
|
22
|
+
export const spin = (
|
|
23
|
+
text = "",
|
|
24
|
+
frames = [
|
|
25
|
+
"⣏⠀⠀",
|
|
26
|
+
"⡟⠀⠀",
|
|
27
|
+
"⠟⠄⠀",
|
|
28
|
+
"⠛⡄⠀",
|
|
29
|
+
"⠙⣄⠀",
|
|
30
|
+
"⠘⣤⠀",
|
|
31
|
+
"⠐⣤⠂",
|
|
32
|
+
"⠀⣤⠃",
|
|
33
|
+
"⠀⣠⠋",
|
|
34
|
+
"⠀⢠⠛",
|
|
35
|
+
"⠀⠠⠻",
|
|
36
|
+
"⠀⠀⢻",
|
|
37
|
+
"⠀⠀⣹",
|
|
38
|
+
"⠀⠀⣼",
|
|
39
|
+
"⠀⠐⣴",
|
|
40
|
+
"⠀⠘⣤",
|
|
41
|
+
"⠀⠙⣄",
|
|
42
|
+
"⠀⠛⡄",
|
|
43
|
+
"⠠⠛⠄",
|
|
44
|
+
"⢠⠛⠀",
|
|
45
|
+
"⣠⠋⠀",
|
|
46
|
+
"⣤⠃⠀",
|
|
47
|
+
"⣦⠂⠀",
|
|
48
|
+
"⣧⠀⠀",
|
|
49
|
+
],
|
|
50
|
+
) => {
|
|
51
|
+
const s = spinner({ frames });
|
|
52
|
+
s.start(text);
|
|
53
|
+
return s;
|
|
54
|
+
};
|
package/src/lib/util.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Loads and parses a JSON file
|
|
6
|
+
* @param filePath The path to the file
|
|
7
|
+
*/
|
|
8
|
+
export async function loadJson<T = unknown>(filePath: string): Promise<T | null> {
|
|
9
|
+
try {
|
|
10
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
11
|
+
return JSON.parse(content) as T;
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Format bytes into a human-readable string with color coding.
|
|
18
|
+
* < 1MB = green
|
|
19
|
+
* >= 1MB = yellow (orange)
|
|
20
|
+
*/
|
|
21
|
+
export function formatSize(bytes: number): string {
|
|
22
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
23
|
+
let unitIndex = 0;
|
|
24
|
+
let size = bytes;
|
|
25
|
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
26
|
+
size /= 1024;
|
|
27
|
+
unitIndex++;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const formatted = `${size.toFixed(1)}${units[unitIndex]}`;
|
|
31
|
+
|
|
32
|
+
if (bytes >= 1024 * 1024) {
|
|
33
|
+
return pc.yellow(formatted);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return pc.green(formatted);
|
|
37
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"outDir": "./dist",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"baseUrl": "./src",
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"paths": {
|
|
13
|
+
"~/*": ["./*"]
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*", "scripts/**/*"]
|
|
17
|
+
}
|