Skip to content

Commit c4e929d

Browse files
committed
feat: added macports resource
1 parent df407a2 commit c4e929d

File tree

6 files changed

+294
-1
lines changed

6 files changed

+294
-1
lines changed

src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { SshKeyResource } from './resources/ssh/ssh-key.js';
3333
import { TerraformResource } from './resources/terraform/terraform.js';
3434
import { VscodeResource } from './resources/vscode/vscode.js';
3535
import { XcodeToolsResource } from './resources/xcode-tools/xcode-tools.js';
36+
import { MacportsResource } from './resources/macports/macports.js';
3637

3738
runPlugin(Plugin.create(
3839
'default',
@@ -69,6 +70,7 @@ runPlugin(Plugin.create(
6970
new WaitGithubSshKey(),
7071
new VenvProject(),
7172
new Pip(),
72-
new PipSync()
73+
new PipSync(),
74+
new MacportsResource()
7375
])
7476
)
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { ParameterSetting, Plan, StatefulParameter, getPty } from 'codify-plugin-lib';
2+
3+
import { codifySpawn } from '../../utils/codify-spawn.js';
4+
import { MacportsConfig } from './macports.js';
5+
6+
export interface PortPackage {
7+
name: string;
8+
version?: string;
9+
}
10+
11+
export class MacportsInstallParameter extends StatefulParameter<MacportsConfig, Array<PortPackage | string>> {
12+
13+
getSettings(): ParameterSetting {
14+
return {
15+
type: 'array',
16+
filterInStatelessMode: (desired, current) =>
17+
current.filter((c) => desired.some((d) => this.isSamePackage(d, c))),
18+
isElementEqual: this.isEqual,
19+
}
20+
}
21+
22+
async refresh(desired: Array<PortPackage | string> | null, config: Partial<MacportsConfig>): Promise<Array<PortPackage | string> | null> {
23+
const $ = getPty()
24+
const { data: installed } = await $.spawnSafe('port echo installed');
25+
26+
if (!installed || installed === '') {
27+
return null;
28+
}
29+
30+
const r = installed.split(/\n/)
31+
.map((l) => {
32+
const [name, version] = l.split(/\s+/)
33+
.filter(Boolean)
34+
35+
return { name, version }
36+
})
37+
.map((installed) => {
38+
if (desired?.find((d) => typeof d === 'string' && d === installed.name)) {
39+
return installed.name;
40+
}
41+
42+
if (desired?.find((d) => typeof d === 'object' && d.name === installed.name && !d.version)) {
43+
return { name: installed.name }
44+
}
45+
46+
return installed;
47+
})
48+
49+
console.log(r)
50+
51+
return r;
52+
}
53+
54+
async add(valueToAdd: Array<PortPackage | string>, plan: Plan<MacportsConfig>): Promise<void> {
55+
await this.install(valueToAdd);
56+
}
57+
58+
async modify(newValue: (PortPackage | string)[], previousValue: (PortPackage | string)[], plan: Plan<MacportsConfig>): Promise<void> {
59+
const valuesToAdd = newValue.filter((n) => !previousValue.some((p) => this.isSamePackage(n, p)));
60+
const valuesToRemove = previousValue.filter((p) => !newValue.some((n) => this.isSamePackage(n, p)));
61+
62+
await this.uninstall(valuesToRemove);
63+
await this.install(valuesToAdd);
64+
}
65+
66+
async remove(valueToRemove: (PortPackage | string)[], plan: Plan<MacportsConfig>): Promise<void> {
67+
await this.uninstall(valueToRemove);
68+
}
69+
70+
private async install(packages: Array<PortPackage | string>): Promise<void> {
71+
const toInstall = packages.map((p) => {
72+
if (typeof p === 'string') {
73+
return p;
74+
}
75+
76+
if (p.version) {
77+
return `${p.name} ${p.version}`;
78+
}
79+
80+
return p.name;
81+
}).join(' ');
82+
83+
await codifySpawn(`port install ${toInstall}`, { requiresRoot: true });
84+
}
85+
86+
private async uninstall(packages: Array<PortPackage | string>): Promise<void> {
87+
const toInstall = packages.map((p) => {
88+
if (typeof p === 'string') {
89+
return p;
90+
}
91+
92+
return p.name;
93+
}).join(' ');
94+
95+
await codifySpawn(`port uninstall ${toInstall}`, { requiresRoot: true });
96+
}
97+
98+
isSamePackage(a: PortPackage | string, b: PortPackage | string): boolean {
99+
if (typeof a === 'string' || typeof b === 'string') {
100+
return a === b;
101+
}
102+
103+
if (typeof a === 'object' && typeof b === 'object') {
104+
return a.name === b.name;
105+
}
106+
107+
return false;
108+
}
109+
110+
isEqual(desired: PortPackage | string, current: PortPackage | string): boolean {
111+
if (typeof desired === 'string' || typeof current === 'string') {
112+
return desired === current;
113+
}
114+
115+
if (typeof desired === 'object' && typeof current === 'object') {
116+
return desired.version
117+
? desired.version === current.version && desired.name === current.name
118+
: desired.name === current.name;
119+
}
120+
121+
return false;
122+
}
123+
124+
125+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema",
3+
"$id": "https://www.codifycli.com/macports.json",
4+
"title": "Macports resource",
5+
"description": "Install macports and manage packages.",
6+
"type": "object",
7+
"properties": {
8+
"install": {
9+
"type": "array",
10+
"description": "Installs packages.",
11+
"items": {
12+
"oneOf": [
13+
{ "type": "string" },
14+
{
15+
"type": "object",
16+
"properties": {
17+
"name": { "type": "string" },
18+
"version": { "type": "string" }
19+
},
20+
"required": ["name"]
21+
}
22+
]
23+
}
24+
}
25+
},
26+
"additionalProperties": false
27+
}

src/resources/macports/macports.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { CreatePlan, Resource, ResourceSettings, SpawnStatus, getPty } from 'codify-plugin-lib';
2+
import { ResourceConfig } from 'codify-schemas';
3+
import * as fsSync from 'node:fs';
4+
import * as fs from 'node:fs/promises';
5+
import os from 'node:os';
6+
import path from 'node:path';
7+
8+
import { codifySpawn } from '../../utils/codify-spawn.js';
9+
import { FileUtils } from '../../utils/file-utils.js';
10+
import { Utils } from '../../utils/index.js';
11+
import { MacportsInstallParameter, PortPackage } from './install-parameter.js';
12+
import schema from './macports-schema.json';
13+
14+
const MACPORTS_DOWNLOAD_LINKS: Record<string, string> = {
15+
'15': 'https://github.com/macports/macports-base/releases/download/v2.10.5/MacPorts-2.10.5-15-Sequoia.pkg',
16+
'14': 'https://github.com/macports/macports-base/releases/download/v2.10.5/MacPorts-2.10.5-14-Sonoma.pkg',
17+
'13': 'https://github.com/macports/macports-base/releases/download/v2.10.5/MacPorts-2.10.5-13-Ventura.pkg',
18+
'12': 'https://github.com/macports/macports-base/releases/download/v2.10.5/MacPorts-2.10.5-12-Monterey.pkg',
19+
'11': 'https://github.com/macports/macports-base/releases/download/v2.10.5/MacPorts-2.10.5-11-BigSur.pkg',
20+
'10': 'https://github.com/macports/macports-base/releases/download/v2.10.5/MacPorts-2.10.5-10.15-Catalina.pkg',
21+
'9': 'https://github.com/macports/macports-base/releases/download/v2.10.5/MacPorts-2.10.5-10.14-Mojave.pkg',
22+
'8': 'https://github.com/macports/macports-base/releases/download/v2.10.5/MacPorts-2.10.5-10.13-HighSierra.pkg',
23+
'7': 'https://github.com/macports/macports-base/releases/download/v2.10.5/MacPorts-2.10.5-10.12-Sierra.pkg',
24+
'6': 'https://github.com/macports/macports-base/releases/download/v2.10.5/MacPorts-2.10.5-10.11-ElCapitan.pkg',
25+
}
26+
27+
export interface MacportsConfig extends ResourceConfig {
28+
install: Array<PortPackage | string>;
29+
}
30+
31+
export class MacportsResource extends Resource<MacportsConfig> {
32+
33+
override getSettings(): ResourceSettings<MacportsConfig> {
34+
return {
35+
id: 'macports',
36+
schema,
37+
parameterSettings: {
38+
install: { type: 'stateful', definition: new MacportsInstallParameter() }
39+
}
40+
};
41+
}
42+
43+
override async refresh(parameters: Partial<MacportsConfig>): Promise<Partial<MacportsConfig> | null> {
44+
console.log(fsSync.readFileSync(path.join(os.homedir(), '.zshrc'), 'utf8'))
45+
const $ = getPty();
46+
47+
const homebrewInfo = await $.spawnSafe('which port');
48+
if (homebrewInfo.status === SpawnStatus.ERROR) {
49+
return null;
50+
}
51+
52+
return parameters;
53+
}
54+
55+
override async create(plan: CreatePlan<MacportsConfig>): Promise<void> {
56+
const macOSVersion = (await codifySpawn('sw_vers --productVersion'))?.data?.split('.')?.at(0);
57+
if (!macOSVersion) {
58+
throw new Error('Unable to determine macOS version');
59+
}
60+
61+
const installerUrl = MACPORTS_DOWNLOAD_LINKS[macOSVersion];
62+
if (!installerUrl) {
63+
throw new Error(`Your current macOS version ${macOSVersion} is not supported. Only ${Object.keys(MACPORTS_DOWNLOAD_LINKS)} is supported`);
64+
}
65+
66+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codify-macports'));
67+
const installerPath = path.join(tmpDir, 'installer.pkg')
68+
69+
console.log(`Downloading macports installer ${installerUrl}`)
70+
await Utils.downloadUrlIntoFile(installerPath, installerUrl);
71+
72+
await codifySpawn(`installer -pkg "${installerPath}" -target /;`, { requiresRoot: true })
73+
74+
await FileUtils.addToStartupFile('')
75+
await FileUtils.addToStartupFile('export PATH=/opt/local/bin:/opt/local/sbin:$PATH')
76+
}
77+
78+
override async destroy(): Promise<void> {
79+
await codifySpawn('port -fp uninstall installed', { requiresRoot: true, throws: false });
80+
await codifySpawn('dscl . -delete /Users/macports', { requiresRoot: true, throws: false });
81+
await codifySpawn('dscl . -delete /Groups/macports', { requiresRoot: true, throws: false });
82+
await codifySpawn('rm -rf \\\n' +
83+
' /opt/local \\\n' +
84+
' /Applications/DarwinPorts \\\n' +
85+
' /Applications/MacPorts \\\n' +
86+
' /Library/LaunchDaemons/org.macports.* \\\n' +
87+
' /Library/Receipts/DarwinPorts*.pkg \\\n' +
88+
' /Library/Receipts/MacPorts*.pkg \\\n' +
89+
' /Library/StartupItems/DarwinPortsStartup \\\n' +
90+
' /Library/Tcl/darwinports1.0 \\\n' +
91+
' /Library/Tcl/macports1.0 \\\n' +
92+
' ~/.macports', { requiresRoot: true, throws: false })
93+
94+
await FileUtils.removeLineFromZshrc('export PATH=/opt/local/bin:/opt/local/sbin:$PATH');
95+
96+
}
97+
98+
}

src/utils/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import * as fs from 'node:fs/promises';
2+
import * as fsSync from 'node:fs';
23

34
import { codifySpawn, SpawnStatus } from './codify-spawn.js';
45
import { SpotlightKind, SpotlightUtils } from './spotlight-search.js';
56
import path from 'node:path';
7+
import { finished } from 'node:stream/promises';
8+
import { Readable } from 'node:stream';
69

710
export const Utils = {
811
async findApplication(name: string): Promise<string[]> {
@@ -92,4 +95,17 @@ export const Utils = {
9295
if (/[^\w/:=-]/.test(arg)) return arg.replaceAll(/([ !"#$%&'()*;<>?@[\\\]`{}~])/g, '\\$1')
9396
return arg;
9497
},
98+
99+
async downloadUrlIntoFile(filePath: string, url: string): Promise<void> {
100+
const { body } = await fetch(url)
101+
102+
const dirname = path.dirname(filePath);
103+
if (!await fs.stat(dirname).then((s) => s.isDirectory()).catch(() => false)) {
104+
await fs.mkdir(dirname, { recursive: true });
105+
}
106+
107+
const ws = fsSync.createWriteStream(filePath)
108+
// Different type definitions here for readable stream (NodeJS vs DOM). Small hack to fix that
109+
await finished(Readable.fromWeb(body as never).pipe(ws));
110+
},
95111
};

test/macports/macports.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
2+
import { PluginTester } from 'codify-plugin-test';
3+
import * as path from 'node:path';
4+
import { execSync } from 'child_process';
5+
import fs from 'node:fs/promises';
6+
import os from 'node:os';
7+
8+
describe('Macports resource integration tests', () => {
9+
const pluginPath = path.resolve('./src/index.ts');
10+
11+
it('Can install and uninstall macports', { timeout: 300000 }, async () => {
12+
// Plans correctly and detects that brew is not installed
13+
await PluginTester.fullTest(pluginPath, [{
14+
type: 'macports',
15+
install: [
16+
{ name: 'libelf', version: '@0.8.13_2' },
17+
'aom'
18+
]
19+
}], {
20+
validateApply: () => {
21+
expect(() => execSync('source ~/.zshrc; which port')).to.not.throw;
22+
},
23+
});
24+
});
25+
})

0 commit comments

Comments
 (0)