@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 ADDED
@@ -0,0 +1 @@
1
+ {}
@@ -0,0 +1,5 @@
1
+ {
2
+ "eslint.validate": [
3
+ "typescript"
4
+ ],
5
+ }
@@ -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!
Binary file
@@ -0,0 +1,7 @@
1
+ import js from '@eslint/js';
2
+ import tseslint from 'typescript-eslint';
3
+
4
+ export default [
5
+ js.configs.recommended,
6
+ ...tseslint.configs.recommended
7
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abcnews/aunty",
3
- "version": "17.0.0-next.2",
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
  }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Put post-install scripts here.
3
+ *
4
+ * Will run after `npm install`.
5
+ */
6
+
7
+ console.log("Hello from postinstall!");
package/src/bin/aunty.ts CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env tsx
2
2
 
3
3
  /**
4
4
  * Aunty - ABC News Digital developer toolkit
@@ -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
+ };
@@ -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
+ }