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
12 changes: 6 additions & 6 deletions integration/extension_list_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,14 +138,14 @@ Deno.test("extension list shows installed extensions", async () => {
"@test/beta": {
version: "2026.01.01.1",
pulledAt: "2026-01-01T00:00:00.000Z",
files: ["extensions/models/beta/model.yaml"],
files: [".swamp/pulled-extensions/models/beta/model.yaml"],
},
"@test/alpha": {
version: "2026.02.01.1",
pulledAt: "2026-02-01T00:00:00.000Z",
files: [
"extensions/models/alpha/model.yaml",
"extensions/models/alpha/handler.ts",
".swamp/pulled-extensions/models/alpha/model.yaml",
".swamp/pulled-extensions/models/alpha/handler.ts",
],
},
});
Expand Down Expand Up @@ -176,7 +176,7 @@ Deno.test("extension list --json shows installed extensions", async () => {
"@test/one": {
version: "2026.01.15.1",
pulledAt: "2026-01-15T12:00:00.000Z",
files: ["extensions/models/one/model.yaml"],
files: [".swamp/pulled-extensions/models/one/model.yaml"],
},
});

Expand Down Expand Up @@ -207,8 +207,8 @@ Deno.test("extension list --verbose shows individual files", async () => {
version: "2026.01.01.1",
pulledAt: "2026-01-01T00:00:00.000Z",
files: [
"extensions/models/verbose-ext/model.yaml",
"extensions/models/verbose-ext/handler.ts",
".swamp/pulled-extensions/models/verbose-ext/model.yaml",
".swamp/pulled-extensions/models/verbose-ext/handler.ts",
],
},
});
Expand Down
4 changes: 4 additions & 0 deletions src/cli/auto_resolver_adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ interface InstallerAdapterConfig {
getExtension: (name: string) => Promise<ExtensionRegistryInfo | null>;
downloadArchive: (name: string, version: string) => Promise<Uint8Array>;
getChecksum: (name: string, version: string) => Promise<string | null>;
/** Full path to the upstream_extensions.json lockfile. */
lockfilePath: string;
modelsDir: string;
workflowsDir: string;
vaultsDir: string;
Expand All @@ -66,6 +68,7 @@ export function createAutoResolveInstallerAdapter(
getExtension,
downloadArchive,
getChecksum,
lockfilePath,
modelsDir,
workflowsDir,
vaultsDir,
Expand All @@ -85,6 +88,7 @@ export function createAutoResolveInstallerAdapter(
downloadArchive,
getChecksum,
logger,
lockfilePath,
modelsDir,
workflowsDir,
vaultsDir,
Expand Down
2 changes: 2 additions & 0 deletions src/cli/commands/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import { Command } from "@cliffy/command";
import { extensionPushCommand } from "./extension_push.ts";
import { extensionPullCommand } from "./extension_pull.ts";
import { extensionInstallCommand } from "./extension_install.ts";
import { extensionFmtCommand } from "./extension_fmt.ts";
import { extensionRemoveCommand } from "./extension_rm.ts";
import { extensionListCommand } from "./extension_list.ts";
Expand All @@ -39,6 +40,7 @@ export const extensionCommand = new Command()
.command("push", extensionPushCommand)
.command("fmt", extensionFmtCommand)
.command("pull", extensionPullCommand)
.command("install", extensionInstallCommand)
.command("rm", extensionRemoveCommand)
.command("list", extensionListCommand)
.command("search", extensionSearchCommand)
Expand Down
133 changes: 133 additions & 0 deletions src/cli/commands/extension_install.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Swamp, an Automation Framework
// Copyright (C) 2026 System Initiative, Inc.
//
// This file is part of Swamp.
//
// Swamp is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License version 3
// as published by the Free Software Foundation, with the Swamp
// Extension and Definition Exception (found in the "COPYING-EXCEPTION"
// file).
//
// Swamp is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with Swamp. If not, see <https://www.gnu.org/licenses/>.

import { Command } from "@cliffy/command";
import { join, resolve } from "@std/path";
import { createContext, type GlobalOptions } from "../context.ts";
import { requireInitializedRepo } from "../repo_context.ts";
import { resolveModelsDir } from "../resolve_models_dir.ts";
import {
RepoMarkerRepository,
} from "../../infrastructure/persistence/repo_marker_repository.ts";
import { RepoPath } from "../../domain/repo/repo_path.ts";
import {
SWAMP_SUBDIRS,
swampPath,
} from "../../infrastructure/persistence/paths.ts";
import {
consumeStream,
createLibSwampContext,
extensionInstall,
requireCurrentExtensionLayout,
resolveServerUrl,
} from "../../libswamp/mod.ts";
import { UserError } from "../../domain/errors.ts";
import { ExtensionApiClient } from "../../infrastructure/http/extension_api_client.ts";
import { createExtensionInstallRenderer } from "../../presentation/renderers/extension_install.ts";

// deno-lint-ignore no-explicit-any
type AnyOptions = any;

export const extensionInstallCommand = new Command()
.name("install")
.description(
"Restore pulled extensions from the lockfile.\n\nReads upstream_extensions.json and re-pulls any extensions whose source\nfiles are missing. Use after cloning a repo or in CI.\nTo add a new extension, use 'swamp extension pull <name>' instead.\n\nExamples:\n swamp extension install",
)
.arguments("[unexpected:string]")
.option("--repo-dir <dir:string>", "Repository directory", { default: "." })
.action(async function (options: AnyOptions, unexpected?: string) {
if (unexpected) {
throw new UserError(
`'swamp extension install' takes no arguments.\n` +
`To add a new extension, use: swamp extension pull ${unexpected}`,
);
}

const cliCtx = createContext(options as GlobalOptions, [
"extension",
"install",
]);
cliCtx.logger.debug`Starting extension install`;

// 1. Validate repo
const repoDir = options.repoDir ?? ".";
await requireInitializedRepo({
repoDir,
outputMode: cliCtx.outputMode,
});

// 2. Resolve lockfile path
const repoPath = RepoPath.create(repoDir);
const markerRepo = new RepoMarkerRepository();
const marker = await markerRepo.read(repoPath);
const modelsDir = resolveModelsDir(marker);
const absoluteModelsDir = resolve(repoDir, modelsDir);
const lockfilePath = join(absoluteModelsDir, "upstream_extensions.json");

// 3. Check for legacy extension layout
await requireCurrentExtensionLayout(lockfilePath);

// 4. Resolve pulled-extension dirs
const pulledModelsDir = swampPath(repoDir, SWAMP_SUBDIRS.pulledModels);
const pulledWorkflowsDir = swampPath(
repoDir,
SWAMP_SUBDIRS.pulledWorkflows,
);
const pulledVaultsDir = swampPath(repoDir, SWAMP_SUBDIRS.pulledVaults);
const pulledDriversDir = swampPath(repoDir, SWAMP_SUBDIRS.pulledDrivers);
const pulledDatastoresDir = swampPath(
repoDir,
SWAMP_SUBDIRS.pulledDatastores,
);
const pulledReportsDir = swampPath(repoDir, SWAMP_SUBDIRS.pulledReports);

// 4. Wire deps and execute
const serverUrl = resolveServerUrl();
const client = new ExtensionApiClient(serverUrl);

const ctx = createLibSwampContext({ logger: cliCtx.logger });
const renderer = createExtensionInstallRenderer(cliCtx.outputMode);

await consumeStream(
extensionInstall(ctx, {
lockfilePath,
repoDir,
createInstallContext: (_name, _version) => ({
getExtension: (n) => client.getExtension(n),
downloadArchive: (n, v) => client.downloadArchive(n, v),
getChecksum: (n, v) => client.getChecksum(n, v),
logger: cliCtx.logger,
lockfilePath,
modelsDir: pulledModelsDir,
workflowsDir: pulledWorkflowsDir,
vaultsDir: pulledVaultsDir,
driversDir: pulledDriversDir,
datastoresDir: pulledDatastoresDir,
reportsDir: pulledReportsDir,
repoDir,
force: true,
alreadyPulled: new Set(),
depth: 0,
}),
}),
renderer.handlers(),
);

cliCtx.logger.debug("Extension install command completed");
});
16 changes: 16 additions & 0 deletions src/cli/commands/extension_list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,19 @@
import { Command } from "@cliffy/command";
import { createContext, type GlobalOptions } from "../context.ts";
import { requireInitializedRepoReadOnly } from "../repo_context.ts";
import { join, resolve } from "@std/path";
import {
consumeStream,
createExtensionListDeps,
createLibSwampContext,
extensionList,
requireCurrentExtensionLayout,
} from "../../libswamp/mod.ts";
import { resolveModelsDir } from "../resolve_models_dir.ts";
import {
RepoMarkerRepository,
} from "../../infrastructure/persistence/repo_marker_repository.ts";
import { RepoPath } from "../../domain/repo/repo_path.ts";
import { createExtensionListRenderer } from "../../presentation/renderers/extension_list.ts";

// deno-lint-ignore no-explicit-any
Expand All @@ -49,6 +56,15 @@ export const extensionListCommand = new Command()
outputMode: cliCtx.outputMode,
});

// Check for legacy extension layout
const repoPath = RepoPath.create(repoDir);
const markerRepo = new RepoMarkerRepository();
const marker = await markerRepo.read(repoPath);
const modelsDir = resolveModelsDir(marker);
const absoluteModelsDir = resolve(repoDir, modelsDir);
const lockfilePath = join(absoluteModelsDir, "upstream_extensions.json");
await requireCurrentExtensionLayout(lockfilePath);

const ctx = createLibSwampContext({ logger: cliCtx.logger });
const deps = await createExtensionListDeps(repoDir);

Expand Down
69 changes: 44 additions & 25 deletions src/cli/commands/extension_pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,19 @@

import { Command } from "@cliffy/command";
import type { Logger } from "@logtape/logtape";
import { join, resolve } from "@std/path";
import { createContext, type GlobalOptions } from "../context.ts";
import { requireInitializedRepo } from "../repo_context.ts";
import { resolveModelsDir } from "../resolve_models_dir.ts";
import { resolveVaultsDir } from "../resolve_vaults_dir.ts";
import { resolveWorkflowsDir } from "../resolve_workflows_dir.ts";
import { resolveDriversDir } from "../resolve_drivers_dir.ts";
import { resolveDatastoresDir } from "../resolve_datastores_dir.ts";
import { resolveReportsDir } from "../resolve_reports_dir.ts";
import {
RepoMarkerRepository,
} from "../../infrastructure/persistence/repo_marker_repository.ts";
import { RepoPath } from "../../domain/repo/repo_path.ts";
import { UserError } from "../../domain/errors.ts";
import {
SWAMP_SUBDIRS,
swampPath,
} from "../../infrastructure/persistence/paths.ts";
import {
ConflictError,
consumeStream,
Expand All @@ -41,6 +41,7 @@ import {
type ExtensionPullDeps,
type ExtensionRegistryInfo,
parseExtensionRef,
requireCurrentExtensionLayout,
resolveServerUrl,
validateExtensionName,
} from "../../libswamp/mod.ts";
Expand Down Expand Up @@ -88,6 +89,8 @@ export interface PullContext {
downloadArchive: (name: string, version: string) => Promise<Uint8Array>;
getChecksum: (name: string, version: string) => Promise<string | null>;
logger: Logger;
/** Full path to the upstream_extensions.json lockfile. */
lockfilePath: string;
modelsDir: string;
workflowsDir: string;
vaultsDir: string;
Expand All @@ -114,6 +117,7 @@ export async function pullExtension(
getExtension: ctx.getExtension,
downloadArchive: ctx.downloadArchive,
getChecksum: ctx.getChecksum,
lockfilePath: ctx.lockfilePath,
modelsDir: ctx.modelsDir,
workflowsDir: ctx.workflowsDir,
vaultsDir: ctx.vaultsDir,
Expand Down Expand Up @@ -160,7 +164,6 @@ export async function pullExtension(

export const extensionPullCommand = new Command()
.name("pull")
.alias("install")
.description("Pull an extension from the swamp registry")
.arguments("<extension:string>")
.option("--repo-dir <dir:string>", "Repository directory", { default: "." })
Expand All @@ -182,27 +185,42 @@ export const extensionPullCommand = new Command()
// 3. Validate name format
validateExtensionName(ref.name);

// 4. Resolve dirs from .swamp.yaml
// 4. Resolve lockfile path (stays in committed extensions/models/ dir)
const repoPath = RepoPath.create(repoDir);
const markerRepo = new RepoMarkerRepository();
const marker = await markerRepo.read(repoPath);
const modelsDir = resolveModelsDir(marker);
const workflowsDir = resolveWorkflowsDir(marker);
const vaultsDir = resolveVaultsDir(marker);
const driversDir = resolveDriversDir(marker);
const datastoresDir = resolveDatastoresDir(marker);
const reportsDir = resolveReportsDir(marker);
const absoluteModelsDir = resolve(repoDir, modelsDir);
const lockfilePath = join(absoluteModelsDir, "upstream_extensions.json");

// 5. Check for legacy extension layout
await requireCurrentExtensionLayout(lockfilePath);

// 6. Resolve pulled-extension dirs (.swamp/pulled-extensions/{type}/)
const pulledModelsDir = swampPath(repoDir, SWAMP_SUBDIRS.pulledModels);
const pulledWorkflowsDir = swampPath(
repoDir,
SWAMP_SUBDIRS.pulledWorkflows,
);
const pulledVaultsDir = swampPath(repoDir, SWAMP_SUBDIRS.pulledVaults);
const pulledDriversDir = swampPath(repoDir, SWAMP_SUBDIRS.pulledDrivers);
const pulledDatastoresDir = swampPath(
repoDir,
SWAMP_SUBDIRS.pulledDatastores,
);
const pulledReportsDir = swampPath(repoDir, SWAMP_SUBDIRS.pulledReports);

// 5. Create deps via factory and pull
// 7. Create deps via factory and pull
const serverUrl = resolveServerUrl();
const deps = createExtensionPullDeps(
serverUrl,
modelsDir,
workflowsDir,
vaultsDir,
driversDir,
datastoresDir,
reportsDir,
lockfilePath,
pulledModelsDir,
pulledWorkflowsDir,
pulledVaultsDir,
pulledDriversDir,
pulledDatastoresDir,
pulledReportsDir,
repoDir,
);

Expand All @@ -211,12 +229,13 @@ export const extensionPullCommand = new Command()
downloadArchive: deps.downloadArchive,
getChecksum: deps.getChecksum,
logger: ctx.logger,
modelsDir,
workflowsDir,
vaultsDir,
driversDir,
datastoresDir,
reportsDir,
lockfilePath,
modelsDir: pulledModelsDir,
workflowsDir: pulledWorkflowsDir,
vaultsDir: pulledVaultsDir,
driversDir: pulledDriversDir,
datastoresDir: pulledDatastoresDir,
reportsDir: pulledReportsDir,
repoDir,
force: options.force ?? false,
outputMode: ctx.outputMode,
Expand Down
Loading
Loading