sh1pt CLI target add/remove patch

Summary

This patch turns sh1pt ship target add/remove from documentation-only stubs into real config-editing subcommands.

Verification

Passed locally.

corepack pnpm exec vitest run packages/cli/src/commands/ship.test.ts
# 10 tests passed

corepack pnpm --filter @profullstack/sh1pt exec tsc -p tsconfig.json --noEmit
# exit 0

Workspace dependency packages were built first so the CLI package could resolve internal workspace exports.

Patch

diff --git a/packages/cli/src/commands/ship.test.ts b/packages/cli/src/commands/ship.test.ts
index 31e7a8f..31d28c7 100644
--- a/packages/cli/src/commands/ship.test.ts
+++ b/packages/cli/src/commands/ship.test.ts
@@ -1,5 +1,10 @@
 import { describe, it, expect } from 'vitest';
-import { availableTargetAdapters, shipCmd } from './ship.js';
+import {
+  availableTargetAdapters,
+  removeTargetFromConfig,
+  shipCmd,
+  upsertTargetInConfig,
+} from './ship.js';
 
 describe('shipCmd', () => {
   it('is registered as a top-level command named "ship"', () => {
@@ -50,4 +55,63 @@ describe('shipCmd', () => {
     expect(availableCmd).toBeDefined();
     expect(availableCmd!.options.map((o) => o.long)).toContain('--json');
   });
+
+  it('adds a target to the init template targets block', () => {
+    const source = [
+      "import { defineConfig } from '@profullstack/sh1pt-core';",
+      '',
+      'export default defineConfig({',
+      "  name: 'demo',",
+      "  version: '0.0.0',",
+      '  targets: {',
+      '    // add targets with `sh1pt ship target add <id>`',
+      '  },',
+      '});',
+      '',
+    ].join('\n');
+
+    const next = upsertTargetInConfig(source, 'pkg-npm');
+
+    expect(next).toContain('"pkg-npm": { use: "target-pkg-npm", config: {} },');
+    expect(next).toContain("version: '0.0.0'");
+  });
+
+  it('updates an existing target without duplicating it', () => {
+    const source = [
+      'export default defineConfig({',
+      '  targets: {',
+      '    "pkg-npm": { use: "target-pkg-npm", enabled: false, config: {} },',
+      '  },',
+      '});',
+    ].join('\n');
+
+    const next = upsertTargetInConfig(source, 'pkg-npm');
+
+    expect(next.match(/"pkg-npm"/g)).toHaveLength(1);
+    expect(next).not.toContain('enabled: false');
+  });
+
+  it('can add a disabled target', () => {
+    const source = 'export default defineConfig({ targets: {} });';
+
+    const next = upsertTargetInConfig(source, 'deploy-vercel', { enabled: false });
+
+    expect(next).toContain('"deploy-vercel": { use: "target-deploy-vercel", enabled: false, config: {} },');
+  });
+
+  it('removes a target from config text', () => {
+    const source = [
+      'export default defineConfig({',
+      '  targets: {',
+      '    "pkg-npm": { use: "target-pkg-npm", config: {} },',
+      '    "deploy-vercel": { use: "target-deploy-vercel", config: {} },',
+      '  },',
+      '});',
+    ].join('\n');
+
+    const next = removeTargetFromConfig(source, 'pkg-npm');
+
+    expect(next).not.toContain('"pkg-npm"');
+    expect(next).toContain('"deploy-vercel"');
+  });
 });
diff --git a/packages/cli/src/commands/ship.ts b/packages/cli/src/commands/ship.ts
index b8effff..d18388c 100644
--- a/packages/cli/src/commands/ship.ts
+++ b/packages/cli/src/commands/ship.ts
@@ -4,7 +4,7 @@ import { pathToFileURL } from 'node:url';
 import kleur from 'kleur';
 import { lint } from '@profullstack/sh1pt-policy';
 import type { Manifest } from '@profullstack/sh1pt-core';
-import { existsSync, statSync } from 'node:fs';
+import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
 import { initAction } from './init.js';
 import { categoryById, packageFor } from '../adapter-registry.js';
 
@@ -158,18 +158,162 @@ export function availableTargetAdapters(): Array<{
   }));
 }
 
+export function upsertTargetInConfig(
+  source: string,
+  id: string,
+  opts: { enabled?: boolean } = {},
+): string {
+  assertKnownTarget(id);
+  const targets = findObjectProperty(source, 'targets');
+  if (!targets) {
+    throw new Error('No targets block found in sh1pt.config.ts. Run sh1pt ship init first.');
+  }
+
+  const withoutExisting = removeObjectEntryFromBody(source, targets.open + 1, targets.close, id);
+  const nextTargets = findObjectProperty(withoutExisting, 'targets');
+  if (!nextTargets) throw new Error('Failed to re-read targets block after update.');
+
+  const entry = formatTargetEntry(id, opts);
+  const body = withoutExisting.slice(nextTargets.open + 1, nextTargets.close);
+  const insertion = body.trim().length === 0 || onlyLineComments(body)
+    ? `\n    ${entry}\n  `
+    : `${body.trimEnd().endsWith(',') ? '' : ','}\n    ${entry}`;
+
+  return withoutExisting.slice(0, nextTargets.close) + insertion + withoutExisting.slice(nextTargets.close);
+}
+
+export function removeTargetFromConfig(source: string, id: string): string {
+  const targets = findObjectProperty(source, 'targets');
+  if (!targets) {
+    throw new Error('No targets block found in sh1pt.config.ts. Run sh1pt ship init first.');
+  }
+  return removeObjectEntryFromBody(source, targets.open + 1, targets.close, id);
+}
+
+function resolveConfigPath(configPathOrDir: string): string {
+  const resolved = resolve(configPathOrDir);
+  const isDirectory = existsSync(resolved) && statSync(resolved).isDirectory();
+  const configPath = isDirectory ? join(resolved, 'sh1pt.config.ts') : resolved;
+  if (!existsSync(configPath)) {
+    throw new Error(`No sh1pt config found at ${configPath}. Run sh1pt ship init first.`);
+  }
+  return configPath;
+}
+
+function assertKnownTarget(id: string): void {
+  if (!availableTargetAdapters().some((target) => target.id === id)) {
+    throw new Error(`Unknown target "${id}". Run sh1pt ship target available to list valid targets.`);
+  }
+}
+
+function formatTargetEntry(id: string, opts: { enabled?: boolean }): string {
+  const enabled = opts.enabled === false ? ', enabled: false' : '';
+  return `${JSON.stringify(id)}: { use: ${JSON.stringify(`target-${id}`)}${enabled}, config: {} },`;
+}
+
+function onlyLineComments(body: string): boolean {
+  return body
+    .split(/\r?\n/)
+    .map((line) => line.trim())
+    .filter(Boolean)
+    .every((line) => line.startsWith('//'));
+}
+
+function removeObjectEntryFromBody(source: string, bodyStart: number, bodyEnd: number, key: string): string {
+  const entry = findTopLevelObjectEntry(source, bodyStart, bodyEnd, key);
+  if (!entry) return source;
+  return source.slice(0, entry.start) + source.slice(entry.end);
+}
+
+function findObjectProperty(source: string, property: string): { open: number; close: number } | undefined {
+  const match = new RegExp(`(?:^|[,{\\s])${escapeRegExp(property)}\\s*:`).exec(source);
+  if (!match) return undefined;
+  const open = source.indexOf('{', match.index + match[0].length);
+  if (open === -1) return undefined;
+  const close = findMatchingBrace(source, open);
+  return close === -1 ? undefined : { open, close };
+}
+
+function findTopLevelObjectEntry(
+  source: string,
+  bodyStart: number,
+  bodyEnd: number,
+  key: string,
+): { start: number; end: number } | undefined {
+  const entryRe = new RegExp(`(?:^|,)\\s*(["']?)${escapeRegExp(key)}\\1\\s*:`, 'g');
+  const body = source.slice(bodyStart, bodyEnd);
+  let match: RegExpExecArray | null;
+  while ((match = entryRe.exec(body))) {
+    const colon = bodyStart + match.index + match[0].length;
+    const open = source.indexOf('{', colon);
+    if (open === -1 || open > bodyEnd) continue;
+    const between = source.slice(colon, open).trim();
+    if (between.length > 0) continue;
+    const close = findMatchingBrace(source, open);
+    if (close === -1 || close > bodyEnd) continue;
+    let start = bodyStart + match.index;
+    let end = close + 1;
+    if (source[end] === ',') end += 1;
+    while (source[end] === '\r' || source[end] === '\n') end += 1;
+    while (start > bodyStart && /[ \t]/.test(source[start - 1]!)) start -= 1;
+    return { start, end };
+  }
+  return undefined;
+}
+
+function findMatchingBrace(source: string, open: number): number {
+  let depth = 0;
+  let quote: '"' | "'" | '`' | undefined;
+  for (let i = open; i < source.length; i += 1) {
+    const ch = source[i];
+    const prev = source[i - 1];
+    if (quote) {
+      if (ch === quote && prev !== '\\') quote = undefined;
+      continue;
+    }
+    if (ch === '"' || ch === "'" || ch === '`') {
+      quote = ch;
+      continue;
+    }
+    if (ch === '{') depth += 1;
+    if (ch === '}') {
+      depth -= 1;
+      if (depth === 0) return i;
+    }
+  }
+  return -1;
+}
+
+function escapeRegExp(input: string): string {
+  return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
 targetSubCmd
   .command('add <id>')
   .description('Add a target adapter to sh1pt.config.ts')
-  .action((id: string) => {
-    console.log(kleur.cyan(`[stub] target add ${id}`));
+  .option('-c, --config <path>', 'path to sh1pt config file or directory', process.cwd())
+  .option('--disabled', 'add the target with enabled: false')
+  .action((id: string, opts: { config: string; disabled?: boolean }) => {
+    const configPath = resolveConfigPath(opts.config);
+    const next = upsertTargetInConfig(readFileSync(configPath, 'utf8'), id, {
+      enabled: opts.disabled ? false : undefined,
+    });
+    writeFileSync(configPath, next, 'utf8');
+    const status = opts.disabled ? 'disabled' : 'enabled';
+    console.log(`${kleur.green('added target')} ${kleur.cyan(id)} ${kleur.dim(`(${status})`)}`);
+    console.log(kleur.dim(`config: ${configPath}`));
   });
 
 targetSubCmd
   .command('remove <id>')
   .description('Remove a target from sh1pt.config.ts')
-  .action((id: string) => {
-    console.log(kleur.yellow(`[stub] target remove ${id}`));
+  .option('-c, --config <path>', 'path to sh1pt config file or directory', process.cwd())
+  .action((id: string, opts: { config: string }) => {
+    const configPath = resolveConfigPath(opts.config);
+    const next = removeTargetFromConfig(readFileSync(configPath, 'utf8'), id);
+    writeFileSync(configPath, next, 'utf8');
+    console.log(`${kleur.yellow('removed target')} ${kleur.cyan(id)}`);
+    console.log(kleur.dim(`config: ${configPath}`));
   });
 
 targetSubCmd
Self-destructs in · 1 view · Keep Forever — $5 · Support · HTMLDrops