@awesomate/hosting-mcp 0.27.0 → 0.31.0

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/dist/index.js CHANGED
@@ -40263,6 +40263,12 @@ async function knowledgeSources(config3, args) {
40263
40263
  if (!args.sourceId || !Array.isArray(args.tags)) return { error: "invalid_request", note: "tag needs sourceId and tags (the full replacement list of free-form tags; collections are unaffected)" };
40264
40264
  return await hubPatch(config3, `/api/knowledge/sources/${encodeURIComponent(args.sourceId)}`, { tags: args.tags });
40265
40265
  }
40266
+ if (args.action === "rename") {
40267
+ if (!args.sourceId || typeof args.title !== "string" || !args.title.trim()) {
40268
+ return { error: "invalid_request", note: "rename needs sourceId and a non-empty title" };
40269
+ }
40270
+ return await hubPatch(config3, `/api/knowledge/sources/${encodeURIComponent(args.sourceId)}`, { title: args.title.trim() });
40271
+ }
40266
40272
  if (args.action === "set_visibility") {
40267
40273
  const ids = args.sourceIds?.length ? args.sourceIds : args.sourceId ? [args.sourceId] : [];
40268
40274
  if (!ids.length || !args.visibility) return { error: "invalid_request", note: "set_visibility needs sourceId (or sourceIds) and visibility" };
@@ -40968,6 +40974,12 @@ var TITLES = {
40968
40974
  awesomate_app_get: "Get app details",
40969
40975
  awesomate_app_scaffold: "Get app scaffold guide",
40970
40976
  awesomate_app_deploy_info: "Get app deploy briefing",
40977
+ awesomate_app_update: "Link repo to app",
40978
+ awesomate_app_repo_check: "Report repo check",
40979
+ awesomate_app_deploy_event: "Record deploy outcome",
40980
+ awesomate_app_migration_plan: "Get migration checklist",
40981
+ awesomate_app_capacity: "Check hosting capacity",
40982
+ awesomate_app_domain_attach: "Attach hostname to app",
40971
40983
  awesomate_app_health: "Check app health",
40972
40984
  awesomate_app_run_state: "Start or stop an app environment",
40973
40985
  awesomate_app_set_env: "Set app secret",
@@ -40980,6 +40992,7 @@ var TITLES = {
40980
40992
  awesomate_n8n_kpis: "Get n8n KPIs",
40981
40993
  awesomate_n8n_node_docs: "Get n8n node docs",
40982
40994
  awesomate_n8n_deploy: "Deploy n8n workflow",
40995
+ awesomate_n8n_library: "Install Awesomate templates",
40983
40996
  awesomate_n8n_test: "Test-fire n8n workflow",
40984
40997
  awesomate_n8n_datatable_write: "Write to n8n data table",
40985
40998
  awesomate_n8n_attach_to_app: "Attach n8n to app",
@@ -41112,6 +41125,39 @@ server.registerTool(
41112
41125
  }
41113
41126
  }
41114
41127
  );
41128
+ server.registerTool(
41129
+ "awesomate_n8n_library",
41130
+ {
41131
+ annotations: annotate("awesomate_n8n_library", MUTATING),
41132
+ description: "The Awesomate template library for THIS account: ready-made n8n automations and AI agents that Awesomate curates, including bundles linked to the account's referrer (e.g. Business Blueprint members see Dale Beaumont's bundles). Actions: 'list' (every bundle the account may see, each with installed, plan_ok and credentials_missing; every plan), 'detail' (slug: the templates in the bundle, what each does, the credential types it needs and whether the account already has each; every plan), 'install_plan' (slug: runs the pre-install checks WITHOUT writing and returns ready | paused | blocked; paused carries a help card per missing credential with where to get it, the exact page on their n8n to create it and our help article; Support Plus+), 'install' (slug: installs; refused unless the plan is ready, or every missing OPTIONAL credential is named in proceed_without and every ambiguous one has a choice in credential_choices; Support Plus+), 'redeem' (token: installs what a redeem token unlocks; Support Plus+). Workflows land INACTIVE; activation, testing and rollback of installed bundles arrive in the next release. A 403 whose missingScopes contains n8n:deploy means the plan is Essentials: show the catalog, say installing needs Support Plus, and stop. Never create a credential for the client; send them to create_url and re-run install_plan afterwards.",
41133
+ inputSchema: {
41134
+ action: external_exports.enum(["list", "detail", "install_plan", "install", "redeem"]),
41135
+ slug: external_exports.string().optional().describe("Bundle slug from list (detail, install_plan, install)"),
41136
+ token: external_exports.string().optional().describe("redeem only: the redeem token Awesomate handed over"),
41137
+ proceed_without: external_exports.array(external_exports.string()).optional().describe("install only: OPTIONAL credential types the client agreed to skip (from install_plan.optional_missing)"),
41138
+ credential_choices: external_exports.record(external_exports.string()).optional().describe("install only: credential type -> credential id the client chose for ambiguous types (from install_plan.ambiguous)")
41139
+ }
41140
+ },
41141
+ async ({ action, slug, token, proceed_without, credential_choices }) => {
41142
+ try {
41143
+ const cfg = requireConfig();
41144
+ if (action === "list") return textResult(await hubGet(cfg, "/api/my-n8n/library"));
41145
+ if (action === "redeem") {
41146
+ if (!token) return errorResult(new Error("token is required for redeem"));
41147
+ return textResult(await hubPost(cfg, "/api/my-n8n/library/redeem", { token }));
41148
+ }
41149
+ if (!slug) return errorResult(new Error(`slug is required for ${action}`));
41150
+ const base = `/api/my-n8n/library/bundles/${encodeURIComponent(slug)}`;
41151
+ if (action === "detail") return textResult(await hubGet(cfg, base));
41152
+ if (action === "install_plan") return textResult(await hubPost(cfg, `${base}/install-plan`, {}));
41153
+ return textResult(
41154
+ await hubPost(cfg, `${base}/install`, { proceed_without: proceed_without ?? [], credential_choices: credential_choices ?? {} })
41155
+ );
41156
+ } catch (err) {
41157
+ return errorResult(err);
41158
+ }
41159
+ }
41160
+ );
41115
41161
  server.registerTool(
41116
41162
  "awesomate_n8n_test",
41117
41163
  {
@@ -41687,7 +41733,7 @@ server.registerTool(
41687
41733
  "awesomate_app_get",
41688
41734
  {
41689
41735
  annotations: annotate("awesomate_app_get", READ_ONLY),
41690
- description: "Get one app plus its environments (subdomains, ports, db engine/name, deploy + health state). Poll this after awesomate_app_create \u2014 status goes provisioning \u2192 active | failed (read provision_error on failure).",
41736
+ description: "Get one app plus its environments (subdomains, ports, db engine/name, deploy + health state) and any hostnames of the user's own attached to them. Poll this after awesomate_app_create \u2014 status goes provisioning \u2192 active | failed (read provision_error on failure).",
41691
41737
  inputSchema: { appId: external_exports.number().int().positive().describe("The app id from awesomate_app_list / _create") }
41692
41738
  },
41693
41739
  async ({ appId }) => {
@@ -41724,12 +41770,15 @@ server.registerTool(
41724
41770
  "awesomate_app_scaffold",
41725
41771
  {
41726
41772
  annotations: annotate("awesomate_app_scaffold", READ_ONLY),
41727
- description: "Fetch the app's starter files (its template rendered against the live app metadata \u2014 subdomains, control-plane URL, repo \u2014 with placeholders already substituted). Call after awesomate_app_create reaches status=active: write each returned file into a fresh local project folder at its relative path, then run npm install (Node apps) and follow the bundled CLAUDE.md. This is how the starter code gets onto the user's machine \u2014 don't reconstruct templates by hand.",
41728
- inputSchema: { appId: external_exports.number().int().positive().describe("The app id from awesomate_app_create / _list") }
41773
+ description: "Fetch the app's starter files (its template rendered against the live app metadata \u2014 subdomains, control-plane URL, repo \u2014 with placeholders already substituted). Call after awesomate_app_create reaches status=active: write each returned file into a fresh local project folder at its relative path, then run npm install (Node apps) and follow the bundled CLAUDE.md. This is how the starter code gets onto the user's machine \u2014 don't reconstruct templates by hand. mode 'adopt' is for a repo that ALREADY exists (Replit, Lovable, anything): it returns only the plumbing (.github/workflows/deploy.yml, .awesomate.json with the deploy contract, scripts/security-check.mjs, scripts/awesomate-migrate.mjs, AWESOMATE.md) to write into that repo without touching its code. Run awesomate_app_repo_check first so the deploy block is pre-filled from the check.",
41774
+ inputSchema: {
41775
+ appId: external_exports.number().int().positive().describe("The app id from awesomate_app_create / _list"),
41776
+ mode: external_exports.enum(["full", "adopt"]).default("full").describe("'full' = the whole starter for a new app; 'adopt' = plumbing only for an existing repo")
41777
+ }
41729
41778
  },
41730
- async ({ appId }) => {
41779
+ async ({ appId, mode }) => {
41731
41780
  try {
41732
- return textResult(await hubGet(requireConfig(), `/api/my-apps/apps/${appId}/scaffold`));
41781
+ return textResult(await hubGet(requireConfig(), `/api/my-apps/apps/${appId}/scaffold${mode === "adopt" ? "?mode=adopt" : ""}`));
41733
41782
  } catch (err) {
41734
41783
  return errorResult(err);
41735
41784
  }
@@ -41739,7 +41788,7 @@ server.registerTool(
41739
41788
  "awesomate_app_deploy_info",
41740
41789
  {
41741
41790
  annotations: annotate("awesomate_app_deploy_info", READ_ONLY),
41742
- description: "READ-ONLY deploy briefing \u2014 this never deploys anything (deploys happen via git push). Reports how to deploy an app, its per-env targets, last-deploy/health state, and how to promote (dev\u2192staging\u2192main) or roll back (git revert + push). Node apps deploy via git push (dev/staging/main \u2192 GitHub Actions \u2192 cPanel). Use the awesomate-github skill to wire push-to-deploy the first time.",
41791
+ description: "READ-ONLY deploy briefing \u2014 this never deploys anything (deploys happen via git push). Reports how to deploy an app, its per-env targets, last-deploy/health state, whether a deploy key is registered, and how to promote (dev\u2192staging\u2192main) or roll back (git revert + push). Node apps deploy via git push (dev/staging/main \u2192 GitHub Actions builds on its runner \u2192 artefacts shipped to cPanel; nothing builds on the account). First-time wiring is awesomate-github/scripts/deploy-key.mjs <appId>, no ticket needed. For an existing repo being moved from Replit or Lovable, start with awesomate_app_migration_plan.",
41743
41792
  inputSchema: { appId: external_exports.number().int().positive().describe("The app id") }
41744
41793
  },
41745
41794
  async ({ appId }) => {
@@ -41750,6 +41799,130 @@ server.registerTool(
41750
41799
  }
41751
41800
  }
41752
41801
  );
41802
+ server.registerTool(
41803
+ "awesomate_app_update",
41804
+ {
41805
+ annotations: annotate("awesomate_app_update", MUTATING),
41806
+ description: "Link an app to the GitHub repo it deploys from and record where the code came from. Call it as soon as the repo is known (owner/repo) for an app being moved from Replit, Lovable or any existing codebase; deploy-key.mjs also calls it for you when it sets the repo secrets. autoTickets:false turns off the hub's automatic support tickets for this app (they are on by default and open a Desk ticket when a rule fires: a committed secret, a blocker reported three times, a deploy failing three times in a day).",
41807
+ inputSchema: {
41808
+ appId: external_exports.number().int().positive().describe("The app id"),
41809
+ githubRepo: external_exports.string().min(3).max(255).optional().describe("'owner/repo' (a github.com URL is accepted and trimmed)"),
41810
+ sourcePlatform: external_exports.enum(["scaffold", "replit", "lovable", "other"]).optional().describe("Where the code came from"),
41811
+ deployKeyFingerprint: external_exports.string().min(1).max(128).optional().describe("Set by deploy-key.mjs; the fingerprint of the per-app deploy key"),
41812
+ readyPath: external_exports.string().regex(/^\/[A-Za-z0-9_\-./]{0,120}$/).optional().describe("Readiness route the health checks probe, default /api/ready"),
41813
+ autoTickets: external_exports.boolean().optional().describe("Whether the hub may open support tickets about this app automatically (default true)")
41814
+ }
41815
+ },
41816
+ async ({ appId, ...body }) => {
41817
+ try {
41818
+ return textResult(await hubPatch(requireConfig(), `/api/my-apps/apps/${appId}`, body));
41819
+ } catch (err) {
41820
+ return errorResult(err);
41821
+ }
41822
+ }
41823
+ );
41824
+ server.registerTool(
41825
+ "awesomate_app_repo_check",
41826
+ {
41827
+ annotations: annotate("awesomate_app_repo_check", MUTATING),
41828
+ description: "Report the JSON produced by `node ~/.claude/skills/awesomate-app-builder/scripts/migrate-check.mjs [--fix]` for the repo this app deploys from. The hub stores it, updates the migration plan (repo_checked becomes done when no blockers remain), pre-fills the deploy block that awesomate_app_scaffold mode 'adopt' returns, and applies its ticket rules: a committed secret opens a Desk ticket at once; the same blocker reported three times opens one; otherwise nothing is sent. Fix blockers in the code, re-run the script, report again. Never paste source code into this call; the report carries rule ids, file paths and one-line details only.",
41829
+ inputSchema: {
41830
+ appId: external_exports.number().int().positive().describe("The app id"),
41831
+ report: external_exports.object({
41832
+ version: external_exports.number().optional(),
41833
+ platform: external_exports.string().optional(),
41834
+ findings: external_exports.array(external_exports.object({ id: external_exports.string(), severity: external_exports.enum(["info", "warn", "blocker"]), title: external_exports.string().optional(), detail: external_exports.string().optional(), file: external_exports.string().optional(), fix: external_exports.string().optional() })).max(200),
41835
+ detected: external_exports.record(external_exports.unknown()).optional(),
41836
+ deploy: external_exports.record(external_exports.unknown()).optional()
41837
+ }).passthrough().describe("The script output, passed through unchanged")
41838
+ }
41839
+ },
41840
+ async ({ appId, report }) => {
41841
+ try {
41842
+ return textResult(await hubPost(requireConfig(), `/api/my-apps/apps/${appId}/repo-check`, { report }));
41843
+ } catch (err) {
41844
+ return errorResult(err);
41845
+ }
41846
+ }
41847
+ );
41848
+ server.registerTool(
41849
+ "awesomate_app_deploy_event",
41850
+ {
41851
+ annotations: annotate("awesomate_app_deploy_event", MUTATING),
41852
+ description: "Record the outcome of a deploy to one environment, after `gh run watch` finishes or the manual artefact deploy exits. Pass outcome 'failure' with the failing step name (from gh run view --log-failed) or 'success' with the commit sha. The hub updates the plan and, after three failures on one env inside a day, opens a support ticket itself and tells you so; do not push again blindly and do not raise the ticket yourself.",
41853
+ inputSchema: {
41854
+ appId: external_exports.number().int().positive().describe("The app id"),
41855
+ env: external_exports.enum(["dev", "staging", "prod"]).describe("Which environment the deploy targeted"),
41856
+ outcome: external_exports.enum(["success", "failure"]),
41857
+ source: external_exports.enum(["workflow", "healthcheck", "manual"]).default("workflow").describe("'workflow' = GitHub Actions, 'manual' = deploy-artifacts.sh"),
41858
+ step: external_exports.string().max(64).optional().describe('Failing workflow step, e.g. "Readiness check"'),
41859
+ sha: external_exports.string().regex(/^[0-9a-f]{40}$/i).optional().describe("Full commit sha that was deployed"),
41860
+ runUrl: external_exports.string().url().max(512).optional().describe("GitHub Actions run URL"),
41861
+ detail: external_exports.string().max(500).optional().describe("One line on what failed; no logs, no secrets")
41862
+ }
41863
+ },
41864
+ async ({ appId, ...body }) => {
41865
+ try {
41866
+ return textResult(await hubPost(requireConfig(), `/api/my-apps/apps/${appId}/deploy-events`, body));
41867
+ } catch (err) {
41868
+ return errorResult(err);
41869
+ }
41870
+ }
41871
+ );
41872
+ server.registerTool(
41873
+ "awesomate_app_migration_plan",
41874
+ {
41875
+ annotations: annotate("awesomate_app_migration_plan", READ_ONLY),
41876
+ description: "The hub-computed checklist for getting an existing app (Replit, Lovable, any repo) running on Awesomate: ordered steps, each done / todo / blocked / not_applicable / unavailable, with the exact tool or script that completes it, and any support ticket the hub has already opened about it. Call it at the start of a migration session and after each step; follow `next`. The capacity step reads the account's real limits (awesomate_app_capacity) and the hostname step attaches the user's own subdomain (awesomate_app_domain_attach).",
41877
+ inputSchema: { appId: external_exports.number().int().positive().describe("The app id") }
41878
+ },
41879
+ async ({ appId }) => {
41880
+ try {
41881
+ return textResult(await hubGet(requireConfig(), `/api/my-apps/apps/${appId}/migration-plan`));
41882
+ } catch (err) {
41883
+ return errorResult(err);
41884
+ }
41885
+ }
41886
+ );
41887
+ server.registerTool(
41888
+ "awesomate_app_capacity",
41889
+ {
41890
+ annotations: annotate("awesomate_app_capacity", MUTATING),
41891
+ description: "Will this app fit on the user's hosting account? Reads the account's live limits and last-day peaks (memory ceiling shared by every environment and the website, task limit where a Node process costs about 11, disk, database sizes, any install or build running on the account, pm2 restart loops) and returns a verdict: ok, warn, blocked or unknown, with reasons and headroom. Call it before the first deploy and again before prod. With fix:true it raises the account's task and memory limits to the platform floors (raise-only, never lowers) when that is what blocks; anything left after that is for Awesomate and the hub opens a support ticket itself and shows it here; do not raise one yourself. Runs a few seconds of server reads; do not poll it.",
41892
+ inputSchema: {
41893
+ appId: external_exports.number().int().positive().describe("The app id"),
41894
+ fix: external_exports.boolean().default(false).describe("Raise the account floors if that is what blocks (Support Plus and above)")
41895
+ }
41896
+ },
41897
+ async ({ appId, fix }) => {
41898
+ try {
41899
+ const cfg = requireConfig();
41900
+ return textResult(fix ? await hubPost(cfg, `/api/my-apps/apps/${appId}/capacity/fix`, {}) : await hubGet(cfg, `/api/my-apps/apps/${appId}/capacity`));
41901
+ } catch (err) {
41902
+ return errorResult(err);
41903
+ }
41904
+ }
41905
+ );
41906
+ server.registerTool(
41907
+ "awesomate_app_domain_attach",
41908
+ {
41909
+ annotations: annotate("awesomate_app_domain_attach", MUTATING),
41910
+ description: "Put the user's own hostname on an app environment (default prod), so the switch from their old platform is a DNS change. The hostname must be a SUBDOMAIN of a domain already on their hosting account (app.theirdomain.com); the apex and www stay with whatever serves them today, usually their website. Creates the cPanel subdomain and the reverse proxy for that environment, queues the certificate, and returns the exact DNS record to set plus whether it already points at Awesomate. Calling it again for a hostname already attached is a cheap DNS re-check (no reconfiguration, no Apache reload); pass reattach:true only to rewrite the plumbing. awesomate_app_get also re-checks DNS for attached hostnames. Node apps only; a domain not yet on the account needs awesomate_domain_add first. A 503 hostname_attach_disabled means Awesomate has not switched this on yet: tell the user, keep prod on its Awesomate address, do not retry.",
41911
+ inputSchema: {
41912
+ appId: external_exports.number().int().positive().describe("The app id"),
41913
+ hostname: external_exports.string().min(4).max(253).describe("e.g. app.careconnect-ai.com.au"),
41914
+ env: external_exports.enum(["dev", "staging", "prod"]).default("prod"),
41915
+ reattach: external_exports.boolean().default(false).describe("Rewrite the subdomain plumbing even though the hostname is already attached")
41916
+ }
41917
+ },
41918
+ async ({ appId, ...body }) => {
41919
+ try {
41920
+ return textResult(await hubPost(requireConfig(), `/api/my-apps/apps/${appId}/domains`, body));
41921
+ } catch (err) {
41922
+ return errorResult(err);
41923
+ }
41924
+ }
41925
+ );
41753
41926
  server.registerTool(
41754
41927
  "awesomate_app_set_env",
41755
41928
  {
@@ -41893,9 +42066,9 @@ server.registerTool(
41893
42066
  "awesomate_knowledge_sources",
41894
42067
  {
41895
42068
  annotations: annotate("awesomate_knowledge_sources", MUTATING),
41896
- description: "The knowledge base's content sources \u2014 the LIBRARY. action 'search' {q?, kind?, visibility?, tags?, collectionIds?, cursor?, limit?} \u2014 find sources by title/tag substring and metadata filters (instant, free; for CONTENT search use awesomate_knowledge_search); each row carries visibility (private|internal|public \u2014 who may retrieve it, ENFORCED), tags and collections. 'tag' {sourceId, tags} \u2014 REPLACE a source's free-form tags. 'set_visibility' {sourceId|sourceIds, visibility, confirm?} \u2014 change who may retrieve the source(s); 'public' is irreversible once fetched, so it needs the user's explicit agreement and their account slug as confirm. 'move' {sourceId|sourceIds, collectionIds?, removeCollectionIds?} \u2014 add to / remove from collections. 'sync_rules' / 'sync_rule_create' {pathPrefix, includeGlobs?, visibility?, tags?, collectionIds?, onFileRemoved?} / 'sync_rule_delete' {ruleId} / 'sync_run' / 'sync_state' \u2014 keep a folder of the account's File Manager (their n8n file system: public/, private/, temp/) synced into the knowledge base, so files their workflows write become searchable; a public rule needs confirm. 'list' \u2014 ONE page of sources, most recently ingested first (default 50, max 200 via limit): read `page.has_more`/`next_cursor` and pass cursor to continue \u2014 a page is never the whole library. 'summary' \u2014 exact whole-library counts {total, by_kind, chunks, indexed_chunks, failed_sources, failed_jobs_7d}: use THIS to say what the knowledge base contains, and if failed_jobs_7d > 0 say so \u2014 those ingests are missing from every other count. 'jobs' \u2014 ingest job statuses (optional status filter: queued|running|succeeded|failed). 'add' \u2014 ingest a public page {url} or a whole site {sitemap, since?}, optionally with visibility (default private), tags and collectionIds; ALWAYS get explicit approval first (ingest costs money and counts against quota), for a local FILE on the user's machine use awesomate_knowledge_upload instead (it streams the file from disk; this tool takes URLs only). A pack_required response means the allowance is exhausted: NOTHING was purchased \u2014 present the pack price (1 credit = $100) and let the user buy from the hub if they want it. 'remove' {sourceId} \u2014 deletes the source AND its indexed content; explicit approval required.",
42069
+ description: "The knowledge base's content sources \u2014 the LIBRARY. action 'search' {q?, kind?, visibility?, tags?, collectionIds?, cursor?, limit?} \u2014 find sources by title/tag substring and metadata filters (instant, free; for CONTENT search use awesomate_knowledge_search); each row carries visibility (private|internal|public \u2014 who may retrieve it, ENFORCED), tags and collections. 'tag' {sourceId, tags} \u2014 REPLACE a source's free-form tags. 'rename' {sourceId, title} \u2014 set the source's display TITLE, which is what every citation shows. An UPLOADED file is titled from its FILENAME slugified into title case (Nasa Coverage \u2026 Reagan S Address), because there is nowhere to put a title at ingest; this is the only way to fix it. Rename AFTER the ingest job succeeds. 'set_visibility' {sourceId|sourceIds, visibility, confirm?} \u2014 change who may retrieve the source(s); 'public' is irreversible once fetched, so it needs the user's explicit agreement and their account slug as confirm. 'move' {sourceId|sourceIds, collectionIds?, removeCollectionIds?} \u2014 add to / remove from collections. 'sync_rules' / 'sync_rule_create' {pathPrefix, includeGlobs?, visibility?, tags?, collectionIds?, onFileRemoved?} / 'sync_rule_delete' {ruleId} / 'sync_run' / 'sync_state' \u2014 keep a folder of the account's File Manager (their n8n file system: public/, private/, temp/) synced into the knowledge base, so files their workflows write become searchable; a public rule needs confirm. 'list' \u2014 ONE page of sources, most recently ingested first (default 50, max 200 via limit): read `page.has_more`/`next_cursor` and pass cursor to continue \u2014 a page is never the whole library. 'summary' \u2014 exact whole-library counts {total, by_kind, chunks, indexed_chunks, failed_sources, failed_jobs_7d}: use THIS to say what the knowledge base contains, and if failed_jobs_7d > 0 say so \u2014 those ingests are missing from every other count. 'jobs' \u2014 ingest job statuses (optional status filter: queued|running|succeeded|failed). 'add' \u2014 ingest a public page {url} or a whole site {sitemap, since?}, optionally with visibility (default private), tags and collectionIds; ALWAYS get explicit approval first (ingest costs money and counts against quota), for a local FILE on the user's machine use awesomate_knowledge_upload instead (it streams the file from disk; this tool takes URLs only). A pack_required response means the allowance is exhausted: NOTHING was purchased \u2014 present the pack price (1 credit = $100) and let the user buy from the hub if they want it. 'remove' {sourceId} \u2014 deletes the source AND its indexed content; explicit approval required.",
41897
42070
  inputSchema: {
41898
- action: external_exports.enum(["list", "summary", "add", "remove", "jobs", "search", "tag", "set_visibility", "move", "sync_rules", "sync_rule_create", "sync_rule_delete", "sync_run", "sync_state"]),
42071
+ action: external_exports.enum(["list", "summary", "add", "remove", "jobs", "search", "tag", "rename", "set_visibility", "move", "sync_rules", "sync_rule_create", "sync_rule_delete", "sync_run", "sync_state"]),
41899
42072
  pathPrefix: external_exports.string().max(512).optional().describe("sync_rule_create: a File Manager folder to keep synced, e.g. 'public/reports'"),
41900
42073
  includeGlobs: external_exports.array(external_exports.string().max(120)).max(20).optional().describe("sync_rule_create: only files matching these globs (relative to the folder), e.g. ['**/*.pdf']"),
41901
42074
  onFileRemoved: external_exports.enum(["keep", "demote_to_private", "delete"]).optional().describe("sync_rule_create: what happens to the knowledge source when the file is removed (default demote_to_private for internal/public rules, keep for private)"),
@@ -41904,6 +42077,7 @@ server.registerTool(
41904
42077
  kind: external_exports.string().max(32).optional().describe("search: source kind filter (web, document, video, audio, image, book, dataset)"),
41905
42078
  visibility: external_exports.enum(["private", "internal", "public"]).optional().describe("add: the audience the new source is cleared for (default private); set_visibility: the level to apply; search: filter"),
41906
42079
  tags: external_exports.array(external_exports.string().max(64)).max(64).optional().describe("add: free-form tags; tag: the REPLACEMENT list; search: filter (AND)"),
42080
+ title: external_exports.string().min(1).max(300).optional().describe("rename: the source's display title, shown in every citation"),
41907
42081
  collectionIds: external_exports.array(external_exports.string().max(64)).max(16).optional().describe("add: put the source in these collections; move: add to; search: filter"),
41908
42082
  removeCollectionIds: external_exports.array(external_exports.string().max(64)).max(16).optional().describe("move: remove from these collections"),
41909
42083
  sourceIds: external_exports.array(external_exports.string().max(300)).max(200).optional().describe("set_visibility/move: several sources at once"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awesomate/hosting-mcp",
3
- "version": "0.27.0",
3
+ "version": "0.31.0",
4
4
  "description": "Awesomate MCP server — lets Claude manage your Awesomate WordPress hosting, plan, limits, n8n automations, and build Node/static apps + databases",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -1,5 +1,39 @@
1
1
  {
2
2
  "versions": [
3
+ {
4
+ "version": "0.31.0",
5
+ "highlights": [
6
+ "Install Awesomate's ready-made automations from Claude Code: awesomate_n8n_library lists the bundles your account may use, including the ones linked to your referrer, explains what each does, and installs them into your n8n",
7
+ "Before anything is written, install_plan checks the credentials each template needs. If one is missing it pauses and tells you where to get it, the exact page on your n8n to create it, and the help article; then you re-run and install",
8
+ "Every credential is yours; nothing is placed in your account on your behalf. Essentials sees the catalog; installing needs Support Plus"
9
+ ]
10
+ },
11
+ {
12
+ "version": "0.30.0",
13
+ "highlights": [
14
+ "Put your own hostname on your app: awesomate_app_domain_attach sets up app.yourdomain.com on production, queues the certificate and tells you the exact DNS record to create, so moving from Replit or Lovable ends in a DNS change rather than a new address",
15
+ "The migration checklist now carries the hostname and DNS steps through to done"
16
+ ]
17
+ },
18
+ {
19
+ "version": "0.29.0",
20
+ "highlights": [
21
+ "Ask Claude whether your app will fit before you deploy: awesomate_app_capacity reads your hosting account's real memory and process limits, last-day peaks, disk and database sizes, and says ok, warn or blocked with the reasons",
22
+ "When the account's limits are the problem, fix:true raises them to the platform floors on the spot; anything that still blocks becomes a support ticket Awesomate opens for you",
23
+ "Your production app is now probed every five minutes; if it stops answering for a quarter of an hour after having been healthy, Awesomate is told and a ticket appears in your plan",
24
+ "Every account that hosts an app is swept nightly so an old process limit cannot sit unnoticed"
25
+ ]
26
+ },
27
+ {
28
+ "version": "0.28.0",
29
+ "highlights": [
30
+ "Moving an app from Replit or Lovable is now a guided checklist: awesomate_app_migration_plan tells Claude exactly what is done, what is next and which tool finishes it, and no support ticket is needed to connect deploys",
31
+ "Push-to-deploy is set up from your own machine: deploy-key.mjs makes a key just for your app, authorises it on your hosting account and sets the three repo secrets",
32
+ "A pre-deploy repo check catches the Replit lockfile that points at a private mirror, the Neon-only database driver, a hardcoded port and a missing readiness route before anything reaches the server",
33
+ "The deploy now builds on GitHub and ships only the built files; nothing builds on your hosting account, which is what made deploys die with Killed",
34
+ "If the check finds a committed secret or a deploy keeps failing, Awesomate opens a support ticket for you and the plan shows the link"
35
+ ]
36
+ },
3
37
  {
4
38
  "version": "0.20.7",
5
39
  "highlights": [
@@ -58,8 +58,8 @@ decision brain. The short version, matched to what they say:
58
58
  single subdomain — there's no dev copy to review on, so every change is
59
59
  live the moment it's published.
60
60
  - **No deploy workflow.** The static template is just the page files, so
61
- pushing to GitHub does not publish it (see the awesomate-github skill's
62
- §2 — deploy wiring needs Awesomate support).
61
+ pushing to GitHub does not publish it; the files are copied to the site's
62
+ document root over the account SSH (awesomate-hosting `ssh-connect.sh`).
63
63
 
64
64
  If the page needs a webhook URL, a secret, or a place to test before it
65
65
  goes live, it's a **Node app**. Say that in one line and move on.
@@ -182,6 +182,47 @@ When the need is "remember/track a list an automation uses" rather than an
182
182
  app with a UI, an n8n data table (awesomate-n8n skill) beats building an app
183
183
  — no hosting slot, no deploy, queryable from workflows directly.
184
184
 
185
+ ## 2b. Bringing an existing app (Replit, Lovable, any repo)
186
+
187
+ Many users arrive with a working app and their code already on GitHub. Do
188
+ not rebuild it. Read [references/migrate-from-replit.md](references/migrate-from-replit.md)
189
+ and let the hub drive: **`awesomate_app_migration_plan` returns the ordered
190
+ checklist for that app with each step done / todo / blocked and the tool or
191
+ script that finishes it. Call it first, follow `next`, call it again after
192
+ every step.** The short shape:
193
+
194
+ 1. `awesomate_app_create` (Node, Postgres) if the app does not exist yet.
195
+ 2. `awesomate_app_update` with the `owner/repo` and `sourcePlatform`.
196
+ 3. `node ~/.claude/skills/awesomate-app-builder/scripts/migrate-check.mjs --fix`
197
+ in the clone, then `awesomate_app_repo_check` with its JSON. Fix blockers
198
+ in the code, re-run, re-report until none remain.
199
+ 4. `awesomate_app_scaffold` with `mode: 'adopt'`: write the returned plumbing
200
+ into the repo (workflow, `.awesomate.json`, security gate, migrate runner,
201
+ `AWESOMATE.md`). Touch nothing else.
202
+ 5. `node ~/.claude/skills/awesomate-github/scripts/deploy-key.mjs <appId>`.
203
+ 6. `awesomate_app_set_env` for each secret per environment.
204
+ 6b. `awesomate_app_capacity` before the first deploy: it reads the account's
205
+ real memory and task limits and last-day peaks. `blocked` with an
206
+ auto-fixable reason → call again with `fix: true`; anything left is for
207
+ Awesomate and the hub has already opened the ticket.
208
+ 7. Push `dev`, `gh run watch`, `awesomate_app_health`, then
209
+ `awesomate_app_deploy_event` with the outcome.
210
+ 8. Data into staging, rehearse, then prod. Stop idle environments after.
211
+ 9. `awesomate_app_domain_attach` puts their own subdomain on prod and returns
212
+ the DNS record; the cutover is that DNS change.
213
+
214
+ **Never build on the hosting account** (no `npm ci` without `--omit=dev`, no
215
+ `npm run build`, no `vite build` over SSH). It shares one memory ceiling with
216
+ the user's other environments and website, and a build there dies with
217
+ "Killed". GitHub Actions builds; `awesomate-hosting/scripts/deploy-artifacts.sh`
218
+ is the manual fallback and builds on the user's machine.
219
+
220
+ **Never raise a support ticket for these problems yourself.** When the repo
221
+ check finds a committed secret, a blocker is reported three times, or a
222
+ deploy fails three times in a day, the hub opens the ticket and the plan
223
+ shows its link. Say "Awesomate has been notified and will pick this up" and
224
+ keep going on what you can fix.
225
+
185
226
  ## 3. Hard rules
186
227
 
187
228
  - Non-technical user: explain in plain words, one or two short questions at a
@@ -201,5 +242,8 @@ app with a UI, an n8n data table (awesomate-n8n skill) beats building an app
201
242
  The honest options are an upgrade or asking support (awesomate-support
202
243
  skill) to remove an app.
203
244
  - Never invent that a capability exists — read `awesomate_app_context` first.
245
+ - Never build on the hosting account, and never raise a support ticket for a
246
+ repo or deploy problem yourself: the hub does both the building (via
247
+ GitHub Actions) and the ticketing (via its rules). See §2b.
204
248
  - Never print a secret or commit a `.env` (the credentials + github skills
205
249
  enforce this).
@@ -0,0 +1,105 @@
1
+ # Moving an app from Replit (or Lovable) to Awesomate hosting
2
+
3
+ The user has a working app somewhere else and their code on their own GitHub.
4
+ The job is to make it run on their Awesomate account in three environments,
5
+ move the data, and switch their address, without anyone at Awesomate doing a
6
+ step by hand. **The hub tells you what is left**: call
7
+ `awesomate_app_migration_plan` at the start of every session and after every
8
+ step; it returns ordered steps with `done` / `todo` / `blocked` and the tool
9
+ or script that completes each one. Follow it rather than this page from
10
+ memory.
11
+
12
+ ## Rules that do not bend
13
+
14
+ - **Never build on the hosting account.** No `npm ci` (without `--omit=dev`),
15
+ no `npm run build`, no `vite build` over SSH. The account has one memory
16
+ ceiling shared by every environment and the user's website; a build there
17
+ is killed mid-run with no useful error. GitHub Actions builds and ships
18
+ artefacts; `awesomate-hosting/scripts/deploy-artifacts.sh` is the manual
19
+ fallback and builds on the user's machine.
20
+ - **Never raise a support ticket for these problems yourself.** The hub opens
21
+ one when a rule fires (a secret in the repo, a blocker reported three times,
22
+ a deploy that fails three times in a day) and the plan shows its link. Tell
23
+ the user "Awesomate has been notified and will pick this up" and keep going
24
+ on what you can fix.
25
+ - **Dev first, then staging with real data, then prod.** Never push `main`
26
+ before the staging rehearsal passes.
27
+
28
+ ## The sequence (what the plan will walk you through)
29
+
30
+ 1. **Provision the app** if it does not exist: `awesomate_app_create` with
31
+ `kind: 'node'`, template `node-crud-postgres` (Postgres), name from the
32
+ user. Three environments with their own database and `.env` appear.
33
+ 2. **Link the repo**: `awesomate_app_update { githubRepo: 'owner/repo',
34
+ sourcePlatform: 'replit' }`.
35
+ 3. **Check the repo**: run
36
+ `node ~/.claude/skills/awesomate-app-builder/scripts/migrate-check.mjs --fix`
37
+ in the clone, then post the JSON with `awesomate_app_repo_check`. Fix
38
+ every `blocker` in the code, re-run, re-post. The common Replit ones:
39
+ - `lockfile_private_registry`: the lockfile resolves packages from
40
+ Replit's mirror. `--fix` regenerates it; commit the result. This is why
41
+ `npm ci` hangs for 14 minutes and dies with an empty log.
42
+ - `neon_serverless_driver`: `@neondatabase/serverless` only talks to
43
+ Neon. Switch to `pg` with `drizzle-orm/node-postgres`.
44
+ - `ready_route_missing`: add `/api/ready` (200 only when `select 1`
45
+ succeeds, else 503). Snippet in the repo's `AWESOMATE.md` after step 4.
46
+ - `port_hardcoded`: `app.listen(Number(process.env.PORT))`.
47
+ - `drizzle_no_generated_sql` (warn): add `drizzle-kit generate` to the
48
+ build, commit `drizzle/`, use the shipped migrate runner. Never `push`.
49
+ - `replit_dev_deps`, `replit_env_reads`, `replit_auth` (warn): remove
50
+ the `@replit/*` plugins, `REPL_ID` reads, and replace Replit Auth.
51
+ 4. **Add the plumbing**: `awesomate_app_scaffold { appId, mode: 'adopt' }`
52
+ returns only `.github/workflows/deploy.yml`, `.awesomate.json`,
53
+ `scripts/security-check.mjs`, `scripts/awesomate-migrate.mjs` and
54
+ `AWESOMATE.md`. Write them into the repo; touch nothing else. Then edit
55
+ the `deploy` block in `.awesomate.json` to match what the check script
56
+ suggested under `deploy` (artifacts, migrate).
57
+ 5. **Deploy key**: `node ~/.claude/skills/awesomate-github/scripts/deploy-key.mjs <appId>`
58
+ in the repo. It mints a key for this app, authorises it on the user's
59
+ account, sets the three repo secrets, and records the repo on the hub.
60
+ 6. **Secrets**: `awesomate_app_set_env` per key per environment. `PORT` and
61
+ `DATABASE_URL` are already there. Replit Secrets map one to one; ask the
62
+ user for values through the awesomate-credentials skill, never in chat.
63
+ 6b. **Capacity**: `awesomate_app_capacity` before the first deploy and again
64
+ before prod. It reports the account's memory ceiling (shared by every
65
+ environment and the website), the task limit (a Node process costs about
66
+ 11), last-day peaks and faults, disk and database sizes, and whether a
67
+ build is running on the account. `blocked` on `nproc_below_floor` or
68
+ `pmem_below_floor` → call again with `fix: true`. Faults, low disk or a
69
+ restart loop are for Awesomate: the hub opens the ticket; tell the user.
70
+ 7. **Dev**: create and push `dev`; `gh run watch`; `awesomate_app_health`.
71
+ Report the outcome with `awesomate_app_deploy_event` (success or failure,
72
+ with the failing step) so the plan and the stuck-deploy rule see it.
73
+ 8. **Data into staging**: on the user's machine,
74
+ `pg_dump --no-owner --no-acl "<old DATABASE_URL>" | gzip > dump.sql.gz`,
75
+ then over their jailed SSH (`awesomate-hosting/scripts/ssh-connect.sh`):
76
+ `gunzip -c dump.sql.gz | ssh ... 'set -a; . ~/apps/<slug>-staging/.env; psql "$DATABASE_URL"'`.
77
+ Push `staging`, exercise the real flows on the staging URL.
78
+ 9. **Prod**: freeze writes on the old platform, repeat the dump into the
79
+ prod database, merge `staging` to `main`, check `/api/ready` and the key
80
+ flows.
81
+ 10. **Address**: `awesomate_app_domain_attach { appId, hostname:
82
+ 'app.theirdomain.com' }`. The hostname must be a subdomain of a domain
83
+ already on the account (their website's domain usually is); the apex and
84
+ `www` stay with the website. It returns the CNAME to set and whether DNS
85
+ already points at us; call it again after they change DNS. Lower the TTL
86
+ before the cutover, then: freeze writes on the old platform, final data
87
+ import, switch the record, check `/api/ready` on the new hostname.
88
+ 11. **Cleanup**: `awesomate_app_run_state` stopped for dev and staging;
89
+ remove Replit-only files; keep the old platform read-only for a week.
90
+
91
+ ## Lovable
92
+
93
+ A Lovable app is a Vite React SPA on Supabase. Supabase stays where it is.
94
+ The app is `kind: 'static'` here; the build output is the site. The check
95
+ script recognises it and stops after the platform section; the static path
96
+ does not need the deploy key or the workflow. Full support is a later release.
97
+
98
+ ## What you cannot do
99
+
100
+ - You cannot put the apex or `www` of their domain on the app; a subdomain
101
+ only (design decision D3). If they insist on the apex, that is a support
102
+ conversation, not a tool call.
103
+ - You cannot open a Desk ticket for platform problems; the hub does.
104
+ - You cannot lower an account limit or raise it above the platform floors;
105
+ `awesomate_app_capacity fix:true` is raise-only up to the floors.
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Pre-deploy check for a repo that was NOT scaffolded by Awesomate (Replit,
4
+ * Lovable, anything). Zero dependencies. Prints a JSON report Claude posts
5
+ * to the hub with awesomate_app_repo_check, which stores it, updates the
6
+ * migration plan, and may open a support ticket for blockers.
7
+ *
8
+ * node migrate-check.mjs [<repo-dir>] [--fix]
9
+ *
10
+ * --fix applies the one repair that is purely mechanical: regenerating a
11
+ * lockfile that points at a private registry. Everything else is reported
12
+ * with the fix spelled out so Claude can do it in the code.
13
+ *
14
+ * Exit code is always 0. Severity: blocker (the deploy will fail or must not
15
+ * happen), warn (will bite later), info (worth knowing).
16
+ */
17
+ import { execSync } from 'node:child_process';
18
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
19
+ import { join, relative, resolve } from 'node:path';
20
+
21
+ const args = process.argv.slice(2);
22
+ const fix = args.includes('--fix');
23
+ const dir = resolve(args.find((a) => !a.startsWith('--')) ?? '.');
24
+
25
+ const findings = [];
26
+ let deploy = null;
27
+ const detected = { node: null, packageManager: 'npm', build: null, start: null, scripts: {}, deps: {}, devDeps: {}, ready_path: '/api/ready', hasReadyRoute: false, drizzle: false, drizzleSql: false, migrationsDir: null, usesPortEnv: false, usesDatabaseUrl: false, replit: {}, websockets: false, largeDirs: [], sourceFiles: 0 };
28
+ const add = (id, severity, title, extra = {}) => findings.push({ id, severity, title, ...extra });
29
+
30
+ const readJson = (p) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } };
31
+ const pkg = readJson(join(dir, 'package.json'));
32
+ if (!pkg) {
33
+ add('no_package_json', 'blocker', 'No package.json at the repo root', { fix: 'Point me at the folder that holds package.json, or this is not a Node app.' });
34
+ finish('unknown');
35
+ }
36
+
37
+ detected.scripts = pkg.scripts ?? {};
38
+ detected.deps = pkg.dependencies ?? {};
39
+ detected.devDeps = pkg.devDependencies ?? {};
40
+ detected.build = detected.scripts.build ?? null;
41
+ detected.start = detected.scripts.start ?? null;
42
+ detected.node = pkg.engines?.node ?? null;
43
+
44
+ // --- platform --------------------------------------------------------------
45
+ const hasReplitFiles = existsSync(join(dir, '.replit')) || existsSync(join(dir, 'replit.nix'));
46
+ const replitDeps = Object.keys({ ...detected.deps, ...detected.devDeps }).filter((d) => d.startsWith('@replit/'));
47
+ const lovable = existsSync(join(dir, 'supabase')) || Object.keys(detected.deps).some((d) => d.startsWith('@supabase/')) && existsSync(join(dir, 'vite.config.ts')) && !detected.deps.express;
48
+ const platform = hasReplitFiles || replitDeps.length ? 'replit' : lovable ? 'lovable' : existsSync(join(dir, '.awesomate.json')) ? 'scaffold' : 'unknown';
49
+ detected.replit = { files: hasReplitFiles, deps: replitDeps };
50
+
51
+ // --- source scan -------------------------------------------------------------
52
+ const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage', 'attached_assets', 'scripts']);
53
+ const SRC_RE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
54
+ const files = [];
55
+ (function walk(d, depth) {
56
+ if (depth > 8) return;
57
+ for (const e of readdirSync(d, { withFileTypes: true })) {
58
+ if (SKIP.has(e.name)) continue;
59
+ const p = join(d, e.name);
60
+ if (e.isDirectory()) walk(p, depth + 1);
61
+ else if (SRC_RE.test(e.name)) files.push(p);
62
+ }
63
+ })(dir, 0);
64
+ detected.sourceFiles = files.length;
65
+
66
+ const SECRET_RE = /(sk-[A-Za-z0-9]{16,}|sk_live_[A-Za-z0-9]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|xox[bp]-[A-Za-z0-9-]{10,}|-----BEGIN [A-Z ]*PRIVATE KEY-----|postgres(ql)?:\/\/[^:\s]+:[^@\s]+@)/;
67
+ let readyFound = false, portEnv = false, portHardcoded = null, dbUrl = false, neon = false, replEnv = [], replitAuth = false, ws = false, evalUse = false;
68
+ for (const f of files) {
69
+ let text;
70
+ try { if (statSync(f).size > 1_000_000) continue; text = readFileSync(f, 'utf8'); } catch { continue; }
71
+ const rel = relative(dir, f);
72
+ if (SECRET_RE.test(text) && !/\.example\./.test(rel)) add('secret_in_source', 'blocker', 'Something shaped like a secret is committed', { file: rel, fix: 'Rotate it with the provider, remove it from the code, store it with awesomate_app_set_env.' });
73
+ if (/['"`]\/api\/ready['"`]/.test(text)) readyFound = true;
74
+ if (/process\.env\.PORT/.test(text)) portEnv = true;
75
+ const listen = text.match(/\.listen\(\s*(\d{4,5})\s*[,)]/);
76
+ if (listen && !/process\.env\.PORT/.test(text)) portHardcoded = { file: rel, port: listen[1] };
77
+ if (/process\.env\.DATABASE_URL/.test(text)) dbUrl = true;
78
+ if (/@neondatabase\/serverless|drizzle-orm\/neon-/.test(text)) neon = true;
79
+ for (const v of ['REPL_ID', 'REPLIT_DOMAINS', 'REPLIT_DEV_DOMAIN', 'REPL_SLUG', 'REPL_OWNER']) if (text.includes(v) && !replEnv.includes(v)) replEnv.push(v);
80
+ if (/replit\.com\/oidc|ISSUER_URL.*replit|openid-client/.test(text) && /REPL/.test(text)) replitAuth = true;
81
+ if (/from ['"](ws|socket\.io)['"]|require\(['"](ws|socket\.io)['"]\)|new WebSocketServer/.test(text)) ws = true;
82
+ if (/\beval\(|new Function\(/.test(text)) evalUse = true;
83
+ }
84
+ detected.hasReadyRoute = readyFound;
85
+ detected.usesPortEnv = portEnv;
86
+ detected.usesDatabaseUrl = dbUrl;
87
+ detected.websockets = ws;
88
+
89
+ // --- tracked .env ------------------------------------------------------------
90
+ try {
91
+ const tracked = execSync('git ls-files .env */.env', { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
92
+ if (tracked) add('tracked_env_file', 'blocker', 'A .env file is tracked by git', { file: tracked.split('\n')[0], fix: 'git rm --cached it, add .env to .gitignore, rotate anything in it.' });
93
+ } catch { /* not a git repo */ }
94
+
95
+ // --- lockfile ----------------------------------------------------------------
96
+ const lockPath = join(dir, 'package-lock.json');
97
+ if (!existsSync(lockPath)) {
98
+ const alt = ['yarn.lock', 'pnpm-lock.yaml', 'bun.lockb'].find((f) => existsSync(join(dir, f)));
99
+ if (alt) detected.packageManager = alt.split('.')[0].replace('lockb', 'bun');
100
+ add('no_npm_lockfile', 'blocker', alt ? `Uses ${alt}; the deploy runs npm ci` : 'No package-lock.json', { fix: 'Run npm install --package-lock-only to create package-lock.json (keep the other lockfile if you like).' });
101
+ } else {
102
+ const lock = readJson(lockPath);
103
+ const hosts = new Set();
104
+ const walkPkgs = (pkgs) => { for (const [, v] of Object.entries(pkgs ?? {})) { if (v?.resolved) { try { hosts.add(new URL(v.resolved).host); } catch { /* ignore */ } } } };
105
+ walkPkgs(lock?.packages); walkPkgs(lock?.dependencies);
106
+ const foreign = [...hosts].filter((h) => h !== 'registry.npmjs.org' && h !== 'github.com' && h !== 'codeload.github.com');
107
+ if (foreign.length) {
108
+ if (fix) {
109
+ try {
110
+ execSync('npm install --package-lock-only --ignore-scripts --registry https://registry.npmjs.org', { cwd: dir, stdio: 'pipe' });
111
+ add('lockfile_private_registry', 'info', `Lockfile pointed at ${foreign.join(', ')}; regenerated against the public registry`, { fix: 'Commit the new package-lock.json.' });
112
+ } catch (err) {
113
+ add('lockfile_private_registry', 'blocker', `Lockfile resolves packages from ${foreign.join(', ')} and regeneration failed`, { detail: String(err.message).slice(0, 300), fix: 'rm package-lock.json && npm install --package-lock-only --registry https://registry.npmjs.org' });
114
+ }
115
+ } else {
116
+ add('lockfile_private_registry', 'blocker', `Lockfile resolves packages from ${foreign.join(', ')}, unreachable from Awesomate and from GitHub Actions`, { fix: 'node migrate-check.mjs --fix (regenerates the lockfile against registry.npmjs.org), then commit it.' });
117
+ }
118
+ }
119
+ }
120
+
121
+ // --- app contract --------------------------------------------------------------
122
+ if (!detected.build) add('no_build_script', 'blocker', 'package.json has no "build" script', { fix: 'Add one that produces the artefacts (for example vite build && esbuild server/index.ts --bundle --platform=node --format=esm --outdir=dist).' });
123
+ if (!readyFound) add('ready_route_missing', 'blocker', 'No /api/ready route', { fix: 'Add a route that returns 200 only when the database answers (see AWESOMATE.md). The deploy and health checks probe it.' });
124
+ if (portHardcoded) add('port_hardcoded', 'blocker', `Server listens on a fixed port ${portHardcoded.port}`, { file: portHardcoded.file, fix: 'Use Number(process.env.PORT); the account assigns one port per environment.' });
125
+ else if (!portEnv) add('port_env_unknown', 'warn', 'Could not confirm the server reads process.env.PORT', { fix: 'Make sure app.listen uses process.env.PORT.' });
126
+ if (!dbUrl && (detected.deps.pg || detected.deps['drizzle-orm'] || detected.deps['@neondatabase/serverless'])) add('database_url_unused', 'warn', 'A database driver is present but DATABASE_URL is not read', { fix: 'Connect with process.env.DATABASE_URL; provisioning writes it per environment.' });
127
+ if (neon || detected.deps['@neondatabase/serverless']) add('neon_serverless_driver', 'blocker', 'Uses the Neon serverless driver, which only speaks to Neon over HTTP/WebSocket', { fix: "Switch to 'pg' with drizzle-orm/node-postgres (npm i pg && npm rm @neondatabase/serverless ws)." });
128
+
129
+ const drizzleCfg = ['drizzle.config.ts', 'drizzle.config.js', 'drizzle.config.mjs'].find((f) => existsSync(join(dir, f)));
130
+ if (drizzleCfg) {
131
+ detected.drizzle = true;
132
+ detected.drizzleSql = existsSync(join(dir, 'drizzle', 'meta', '_journal.json'));
133
+ if (!detected.drizzleSql) add('drizzle_no_generated_sql', 'warn', 'Drizzle is configured but no generated SQL is committed; drizzle-kit push cannot reach the account database', { fix: 'Add "drizzle-kit generate" to the build script, commit drizzle/, add "drizzle" to deploy.artifacts and set deploy.migrate to "node --env-file=.env scripts/awesomate-migrate.mjs".' });
134
+ } else if (existsSync(join(dir, 'migrations'))) {
135
+ detected.migrationsDir = 'migrations';
136
+ }
137
+
138
+ if (replitDeps.length) add('replit_dev_deps', 'warn', `Replit-only packages present: ${replitDeps.join(', ')}`, { fix: 'Remove them from package.json and vite.config.ts; they do nothing outside Replit.' });
139
+ if (hasReplitFiles) add('replit_config_files', 'info', '.replit / replit.nix present', { fix: 'Harmless; delete when the move is done.' });
140
+ if (replEnv.length) add('replit_env_reads', 'warn', `Code reads Replit-only variables: ${replEnv.join(', ')}`, { fix: 'Replace with your own configuration or remove; they are unset on Awesomate.' });
141
+ if (replitAuth) add('replit_auth', 'warn', 'Replit Auth (OIDC against replit.com) is used for sign-in', { fix: 'It stops working off Replit. Replace with your own auth (session + password, or another OIDC provider).' });
142
+ if (ws) add('websockets_proxy', 'info', 'WebSockets are used', { fix: 'Nothing to do: the reverse proxy forwards the Upgrade handshake on every environment address and attached hostname.' });
143
+ if (evalUse) add('dynamic_code_eval', 'warn', 'eval() or new Function() in source', { fix: 'The prod security gate blocks these; replace before merging to main.' });
144
+ if (detected.node && !/(^|[^0-9])22([^0-9]|$)|>=\s*(1[8-9]|2[0-2])/.test(String(detected.node))) add('node_engine_mismatch', 'warn', `engines.node is "${detected.node}"; Awesomate runs Node 22`, { fix: 'Test on Node 22 locally; update engines.' });
145
+ if (pkg.scripts?.start && /NODE_ENV=/.test(pkg.scripts.start)) add('start_sets_node_env', 'info', 'start script sets NODE_ENV inline', { fix: 'Not needed: the account .env sets NODE_ENV=production. deploy.start is the entry file, e.g. dist/index.js.' });
146
+ // The Vite pattern that killed careconnect's first deploy: dev-only middleware
147
+ // behind a NODE_ENV check, imported at the top level. NODE_ENV is "production"
148
+ // on every Awesomate environment, so the dev branch never runs; a TOP-LEVEL
149
+ // import of vite still fails at startup because vite is not installed.
150
+ const viteServerFiles = files.filter((f) => /import[^;]*['"]vite['"]|from ['"]vite['"]|require\(['"]vite['"]\)/.test((() => { try { return readFileSync(f, 'utf8'); } catch { return ''; } })()));
151
+ for (const f of viteServerFiles) {
152
+ const rel = relative(dir, f);
153
+ if (/^(vite\.config|client\/)/.test(rel)) continue;
154
+ add('vite_imported_at_runtime', 'blocker', `${rel} imports vite, which is a devDependency and is NOT installed on the server`, {
155
+ file: rel,
156
+ fix: "Import vite only inside the branch that needs it (await import('vite') behind a NODE_ENV !== 'production' check), or better: serve the built client with express.static and delete the dev-server path from the deployed bundle. Awesomate sets NODE_ENV=production on every environment (use AWESOMATE_ENV to tell dev from prod).",
157
+ });
158
+ }
159
+ if (existsSync(join(dir, 'main.py'))) add('python_file_present', 'info', 'main.py present', { fix: 'Only Node runs on the account; delete it if it is a leftover.' });
160
+
161
+ for (const name of readdirSync(dir, { withFileTypes: true })) {
162
+ if (!name.isDirectory() || SKIP.has(name.name) && name.name !== 'attached_assets') continue;
163
+ let size = 0, count = 0;
164
+ (function du(p, depth) { if (depth > 6) return; for (const e of readdirSync(p, { withFileTypes: true })) { const q = join(p, e.name); if (e.isDirectory()) du(q, depth + 1); else { count += 1; try { size += statSync(q).size; } catch { /* ignore */ } } } })(join(dir, name.name), 0);
165
+ if (size > 50 * 1024 * 1024) { detected.largeDirs.push({ dir: name.name, mb: Math.round(size / 1048576), files: count }); add('large_assets_dir', 'warn', `${name.name}/ is ${Math.round(size / 1048576)} MB (${count} files)`, { fix: 'Do not ship it through the deploy; serve static assets from public_html or object storage and keep the repo small.' }); }
166
+ }
167
+
168
+ // --- suggested deploy contract ---------------------------------------------------
169
+ const artifacts = ['dist', 'package.json', 'package-lock.json'];
170
+ if (detected.drizzle) artifacts.splice(1, 0, 'drizzle');
171
+ else if (detected.migrationsDir) artifacts.splice(1, 0, 'migrations');
172
+ deploy = {
173
+ node: 22,
174
+ build: detected.drizzle && !/drizzle-kit generate/.test(detected.build ?? '') ? 'npx drizzle-kit generate && npm run build' : 'npm run build',
175
+ artifacts,
176
+ install: 'npm ci --omit=dev',
177
+ migrate: detected.drizzle ? 'node --env-file=.env scripts/awesomate-migrate.mjs' : detected.migrationsDir ? 'node --env-file=.env scripts/awesomate-migrate.mjs migrations' : null,
178
+ start: 'dist/index.js',
179
+ ready_path: '/api/ready',
180
+ };
181
+
182
+ finish(platform);
183
+
184
+ function finish(plat) {
185
+ const blockers = findings.filter((f) => f.severity === 'blocker').length;
186
+ const report = { version: 1, platform: plat, findings, detected, deploy, summary: { blockers, warnings: findings.filter((f) => f.severity === 'warn').length, fixApplied: fix } };
187
+ console.log(JSON.stringify(report, null, 2));
188
+ process.exit(0);
189
+ }
@@ -59,29 +59,44 @@ git checkout -b dev
59
59
  git push -u origin dev
60
60
  ```
61
61
 
62
- ## 2. Push-to-deploy (for apps built with awesomate-app-builder)
63
-
64
- Node apps from the app builder ship with a GitHub Actions workflow that
65
- deploys on a push to `dev` / `staging` / `main` (dev → dev, main → prod).
66
- Once the branches exist, explain it as *"save your work = it goes live"* —
67
- don't lecture on branches.
68
-
69
- **But you cannot finish the wiring yourself, and you should say so.** That
70
- workflow needs `SSH_HOST`, `SSH_USER` and `SSH_PRIVATE_KEY` as repo secrets,
71
- and **no client-reachable tool or route returns those values** — they're
72
- admin-only. `github-setup.mjs --deploy-secrets` only copies them from your
73
- environment, so with nothing there it prints `skip deploy secret …` and the
74
- first deploy run goes red. So:
75
-
76
- 1. Set up the repo, the branches and the commits (§1) — all of that works.
77
- 2. Tell the user plainly: *"your code is backed up on GitHub; the automatic
78
- deploy still needs Awesomate to connect it — I'll raise that for you."*
79
- Raise it via the **awesomate-support** skill.
80
- 3. **Do not loop on the failing pipeline.** Re-running it, rewriting the
81
- workflow, or pushing again won't produce the missing secrets.
82
-
83
- Static sites have no deploy workflow at all — the template is just the page
84
- files — so pushing does not publish them either; same handoff.
62
+ ## 2. Push-to-deploy (Node apps on Awesomate)
63
+
64
+ Node apps deploy on a push to `dev` / `staging` / `main` (dev → dev, main →
65
+ prod). GitHub Actions builds on its own runner and ships only the built
66
+ files to the account; **nothing builds on the hosting account**, and neither
67
+ should you over SSH. Once wired, explain it as *"save your work = it goes
68
+ live"*; don't lecture on branches.
69
+
70
+ **Wiring it is one command, run from the repo folder, and needs no ticket:**
71
+
72
+ ```
73
+ node ~/.claude/skills/awesomate-github/scripts/deploy-key.mjs <appId>
74
+ ```
75
+
76
+ It generates a key **for this app only**, authorises it on the user's own
77
+ hosting account through the hub (Support Plus and above), sets
78
+ `SSH_PRIVATE_KEY`, `SSH_HOST` and `SSH_USER` on the repo with `gh secret set`,
79
+ shreds the private key locally, and tells the hub which repo the app deploys
80
+ from. Re-running it is safe; `--rotate` replaces the key. `<appId>` comes from
81
+ `awesomate_app_list` / `awesomate_app_get`.
82
+
83
+ The repo also needs the Awesomate plumbing: a scaffolded app already has it;
84
+ an existing repo (Replit, Lovable, anything) gets it from
85
+ `awesomate_app_scaffold` with `mode: 'adopt'` after the repo check. The full
86
+ order for an existing repo lives in the app-builder skill's
87
+ `references/migrate-from-replit.md`, and `awesomate_app_migration_plan` tells
88
+ you which step is next.
89
+
90
+ **When a deploy run goes red**, read the failing step (`gh run view
91
+ --log-failed`), report it with `awesomate_app_deploy_event`, fix the cause,
92
+ and push again. Three failures on one environment inside a day make the hub
93
+ open a support ticket itself and show it in the plan; tell the user
94
+ "Awesomate has been notified" and keep working. Do not raise that ticket
95
+ yourself and do not loop on pushes.
96
+
97
+ Static sites have no deploy workflow at all: the template is just the page
98
+ files, so pushing does not publish them. They are copied to the site's
99
+ document root over the account SSH (awesomate-hosting `ssh-connect.sh`).
85
100
 
86
101
  ## 3. Everyday use (plain language)
87
102
 
@@ -107,13 +122,13 @@ control — they should never have to think about it:
107
122
  aren't backed up anywhere — want me to set that up? Takes a minute."*
108
123
  `env_file_tracked` is urgent: untrack it, .gitignore it, and route the
109
124
  secrets through awesomate-credentials.
110
- **`last_deploy_failed` that never clears is usually the unwired deploy
111
- secrets (§2), not a bug you can fix.** Check whether `SSH_HOST` /
112
- `SSH_USER` / `SSH_PRIVATE_KEY` are set on the repo (`gh secret list`)
113
- before touching anything: if they're missing, stop, tell the user the
114
- deploy connection is pending with Awesomate, and raise it via the
115
- awesomate-support skill. Don't retry the run, and don't start editing the
116
- workflow file.
125
+ **`last_deploy_failed`:** first check the three deploy secrets exist
126
+ (`gh secret list`). If they are missing, run `deploy-key.mjs <appId>` (§2);
127
+ that is the fix, not a ticket. If they exist, read the failing step with
128
+ `gh run view --log-failed`, report it with `awesomate_app_deploy_event`,
129
+ fix the cause and push once. A "Killed" or exit 137 inside a step that runs
130
+ on the account means memory: the build must move to the runner (it does by
131
+ default; check `.awesomate.json` deploy.build is not being run over SSH).
117
132
  - **Auto-checkpoint.** After every meaningful working change (feature works,
118
133
  bug fixed, content updated), commit with a plain-English message — don't
119
134
  wait for "save my work", and never ask them to write a message. Push at
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Wire push-to-deploy for an Awesomate app from the client's own machine.
4
+ *
5
+ * node deploy-key.mjs <appId> [--path <repo-dir>] [--rotate]
6
+ *
7
+ * 1. Generates a fresh ed25519 keypair for THIS app (never the machine key).
8
+ * 2. Registers the public half on the hosting account through the hub
9
+ * (POST /api/hosting-access/ssh-keys, Support Plus and above).
10
+ * 3. Sets SSH_PRIVATE_KEY, SSH_HOST and SSH_USER as GitHub Actions secrets on
11
+ * the repo in <repo-dir> (gh CLI, already authenticated).
12
+ * 4. Shreds the private key locally and tells the hub which repo and key
13
+ * fingerprint the app now uses (PATCH /api/my-apps/apps/<id>).
14
+ *
15
+ * Idempotent: if the repo already has the three secrets and --rotate is not
16
+ * given, it only refreshes the hub's record. --rotate mints a new key AND
17
+ * revokes the previous one from the hosting account, so a leaked key stops
18
+ * working. Prints names and fingerprints, never key material.
19
+ */
20
+ import { spawnSync } from 'node:child_process';
21
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
22
+ import { tmpdir, homedir } from 'node:os';
23
+ import { join } from 'node:path';
24
+
25
+ const args = process.argv.slice(2);
26
+ const flags = new Set(args.filter((a) => a.startsWith('--')));
27
+ const positional = args.filter((a) => !a.startsWith('--'));
28
+ const appId = Number(positional[0]);
29
+ const pathIdx = args.indexOf('--path');
30
+ const cwd = pathIdx >= 0 && args[pathIdx + 1] ? args[pathIdx + 1] : process.cwd();
31
+ const rotate = flags.has('--rotate');
32
+
33
+ if (!Number.isInteger(appId) || appId <= 0) {
34
+ console.error('usage: node deploy-key.mjs <appId> [--path <repo-dir>] [--rotate]');
35
+ console.error('appId: the number from awesomate_app_list / awesomate_app_get');
36
+ process.exit(2);
37
+ }
38
+
39
+ function run(cmd, cmdArgs, opts = {}) {
40
+ return spawnSync(cmd, cmdArgs, { cwd, encoding: 'utf8', ...opts });
41
+ }
42
+ function die(msg) {
43
+ console.error(`\n✗ ${msg}`);
44
+ process.exit(1);
45
+ }
46
+
47
+ // --- account: API base, token and SSH block from the hosting skill's resolver --
48
+ const resolver = join(homedir(), '.claude', 'skills', 'awesomate-hosting', 'scripts', 'resolve-account.mjs');
49
+ if (!existsSync(resolver)) {
50
+ die('The awesomate-hosting skill is not installed on this machine. Connect Claude Code from hub.awesomate.ai/sites first.');
51
+ }
52
+ const resolved = spawnSync('node', [resolver, '--api', '--ssh'], { encoding: 'utf8' });
53
+ if (resolved.status !== 0) {
54
+ die((resolved.stderr || resolved.stdout || '').trim() || 'Could not resolve the connected Awesomate account.');
55
+ }
56
+ const kv = {};
57
+ for (const part of resolved.stdout.trim().split(';')) {
58
+ const m = part.trim().match(/^([A-Z]+)='(.*)'$/);
59
+ if (m) kv[m[1]] = m[2];
60
+ }
61
+ const { ACCT, API, PAT, H: sshHost, U: sshUser } = kv;
62
+ if (!API || !PAT) die('No access token found for this account. Reconnect from hub.awesomate.ai/sites.');
63
+ if (!sshHost || !sshUser) die('This plan has no shell access (Support Plus and above), so a deploy key cannot be authorised.');
64
+ console.log(`→ account: ${ACCT} hosting user: ${sshUser}@${sshHost}`);
65
+
66
+ async function hub(method, path, body) {
67
+ const res = await fetch(`${API}${path}`, {
68
+ method,
69
+ headers: { Authorization: `Bearer ${PAT}`, 'Content-Type': 'application/json', Accept: 'application/json' },
70
+ body: body === undefined ? undefined : JSON.stringify(body),
71
+ });
72
+ const text = await res.text();
73
+ let json = {};
74
+ try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text.slice(0, 200) }; }
75
+ if (!res.ok) {
76
+ const msg = json.message || json.error || `${res.status}`;
77
+ throw new Error(`${method} ${path} → ${res.status}: ${msg}`);
78
+ }
79
+ return json;
80
+ }
81
+
82
+ // --- repo ------------------------------------------------------------------
83
+ if (run('gh', ['--version']).status !== 0) die("GitHub CLI 'gh' is not installed (macOS: brew install gh).");
84
+ if (run('gh', ['auth', 'status']).status !== 0) die('Not connected to GitHub. Run: gh auth login');
85
+ const repoView = run('gh', ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner']);
86
+ if (repoView.status !== 0) die(`No GitHub repo in ${cwd}. Run github-setup.mjs first.`);
87
+ const repo = repoView.stdout.trim();
88
+ console.log(`→ repo: ${repo}`);
89
+
90
+ const existing = run('gh', ['secret', 'list', '--json', 'name', '-q', '.[].name']).stdout.split(/\r?\n/).filter(Boolean);
91
+ const haveAll = ['SSH_PRIVATE_KEY', 'SSH_HOST', 'SSH_USER'].every((n) => existing.includes(n));
92
+
93
+ let previousFingerprint = null;
94
+ try {
95
+ const current = await hub('GET', `/api/my-apps/apps/${appId}`);
96
+ previousFingerprint = current?.app?.deploy_key_fingerprint ?? null;
97
+ } catch { /* the hub record is optional here */ }
98
+
99
+ let fingerprint = null;
100
+ if (haveAll && !rotate) {
101
+ console.log('• deploy secrets already present on the repo (pass --rotate to replace the key)');
102
+ } else {
103
+ // --- key ----------------------------------------------------------------
104
+ const dir = mkdtempSync(join(tmpdir(), 'awm-deploy-key-'));
105
+ const keyPath = join(dir, 'key');
106
+ try {
107
+ const gen = spawnSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-C', `awesomate-deploy:${ACCT}:app${appId}`, '-f', keyPath], { encoding: 'utf8' });
108
+ if (gen.status !== 0) die(`ssh-keygen failed: ${(gen.stderr || '').trim()}`);
109
+ const publicKey = readFileSync(`${keyPath}.pub`, 'utf8').trim();
110
+
111
+ const reg = await hub('POST', '/api/hosting-access/ssh-keys', {
112
+ publicKey,
113
+ label: `deploy:app${appId} (${repo})`,
114
+ });
115
+ fingerprint = reg.fingerprint ?? null;
116
+ const host = reg.sshHost || sshHost;
117
+ const user = reg.sshUser || sshUser;
118
+ console.log(`• deploy key authorised on ${user}@${host}${fingerprint ? ` (${fingerprint})` : ''}`);
119
+
120
+ const priv = readFileSync(keyPath, 'utf8');
121
+ for (const [name, value] of [['SSH_PRIVATE_KEY', priv], ['SSH_HOST', host], ['SSH_USER', user]]) {
122
+ const set = run('gh', ['secret', 'set', name], { input: value });
123
+ if (set.status !== 0) die(`gh secret set ${name} failed: ${(set.stderr || '').trim()}`);
124
+ console.log(`• set repo secret ${name}`);
125
+ }
126
+ } finally {
127
+ // Overwrite before unlinking so the private half does not linger in tmp.
128
+ try { if (existsSync(keyPath)) writeFileSync(keyPath, '\0'.repeat(4096)); } catch { /* best effort */ }
129
+ rmSync(dir, { recursive: true, force: true });
130
+ }
131
+ }
132
+
133
+ // --- revoke the previous key on rotate --------------------------------------
134
+ if (rotate && fingerprint && previousFingerprint && previousFingerprint !== fingerprint) {
135
+ try {
136
+ const list = await hub('GET', '/api/hosting-access/ssh-keys');
137
+ const old = (list?.keys ?? []).find((k) => k.fingerprint === previousFingerprint);
138
+ if (old) {
139
+ await hub('DELETE', `/api/hosting-access/ssh-keys/${old.id}`);
140
+ console.log(`• revoked the previous deploy key (${previousFingerprint})`);
141
+ } else {
142
+ console.log(`• previous key ${previousFingerprint} is not in the hub registry; revoke it from hub.awesomate.ai/settings?tab=access if it still exists`);
143
+ }
144
+ } catch (err) {
145
+ console.log(`• could not revoke the previous key (${err.message}); revoke it from hub.awesomate.ai/settings?tab=access`);
146
+ }
147
+ }
148
+
149
+ // --- tell the hub -----------------------------------------------------------
150
+ const patch = { githubRepo: repo };
151
+ if (fingerprint) patch.deployKeyFingerprint = fingerprint;
152
+ try {
153
+ await hub('PATCH', `/api/my-apps/apps/${appId}`, patch);
154
+ console.log(`• hub updated: app ${appId} deploys from ${repo}`);
155
+ } catch (err) {
156
+ console.log(`• could not update the hub record (${err.message}); the secrets are set, so deploys will work. Ask Claude to run awesomate_app_update.`);
157
+ }
158
+
159
+ console.log('\n✓ Push-to-deploy is wired. Push the dev branch to deploy dev; gh run watch to follow.');
@@ -2,15 +2,15 @@
2
2
  /**
3
3
  * Zero-knowledge GitHub scaffolder. Idempotent — safe to re-run.
4
4
  *
5
- * node github-setup.mjs <repo-name> [--public] [--path <dir>] [--deploy-secrets]
5
+ * node github-setup.mjs <repo-name> [--public] [--path <dir>]
6
6
  *
7
7
  * Steps: verify gh auth → ensure .gitignore excludes .env → SECRET GUARD
8
8
  * (refuse to commit a .env or an obvious key/token) → git init + first commit →
9
- * `gh repo create --source=. --push` (private by default; personal accounts OK)
10
- * → optionally seed deploy Actions secrets from the environment.
9
+ * `gh repo create --source=. --push` (private by default; personal accounts OK).
11
10
  *
12
- * Deploy secrets are read from env vars (SSH_HOST/SSH_USER/SSH_PRIVATE_KEY), not
13
- * argv, so they never appear in `ps`. Prints only non-sensitive output.
11
+ * Deploy secrets are NOT handled here: run deploy-key.mjs <appId> afterwards,
12
+ * which mints a per-app key and sets SSH_HOST/SSH_USER/SSH_PRIVATE_KEY itself.
13
+ * Prints only non-sensitive output.
14
14
  */
15
15
 
16
16
  import { spawnSync } from 'node:child_process';
@@ -24,10 +24,9 @@ const repoName = positional[0];
24
24
  const pathIdx = args.indexOf('--path');
25
25
  const cwd = pathIdx >= 0 && args[pathIdx + 1] ? args[pathIdx + 1] : process.cwd();
26
26
  const isPublic = flags.has('--public');
27
- const wantDeploySecrets = flags.has('--deploy-secrets');
28
27
 
29
28
  if (!repoName || !/^[a-z0-9][a-z0-9._-]{0,99}$/i.test(repoName)) {
30
- console.error('usage: node github-setup.mjs <repo-name> [--public] [--path <dir>] [--deploy-secrets]');
29
+ console.error('usage: node github-setup.mjs <repo-name> [--public] [--path <dir>]');
31
30
  console.error('repo-name: letters/digits/._- , starts alphanumeric');
32
31
  process.exit(2);
33
32
  }
@@ -111,19 +110,6 @@ if (already.status === 0) {
111
110
  console.log(`• created ${isPublic ? 'public' : 'private'} repo and pushed`);
112
111
  }
113
112
 
114
- // --- 5. deploy secrets (values from env, never argv) ----------------------
115
- if (wantDeploySecrets) {
116
- for (const name of ['SSH_HOST', 'SSH_USER', 'SSH_PRIVATE_KEY']) {
117
- const val = process.env[name];
118
- if (!val) {
119
- console.log(`• skip deploy secret ${name} (not in environment)`);
120
- continue;
121
- }
122
- const set = run('gh', ['secret', 'set', name], { input: val });
123
- console.log(set.status === 0 ? `• set deploy secret ${name}` : `• FAILED to set ${name}: ${(set.stderr || '').trim()}`);
124
- }
125
- }
126
-
127
113
  // --- done ------------------------------------------------------------------
128
114
  const url = run('gh', ['repo', 'view', '--json', 'url', '-q', '.url']).stdout.trim();
129
115
  console.log(`\n✓ Done. Your project is on GitHub${url ? `: ${url}` : ''}.`);
@@ -362,6 +362,11 @@ skill drive the Git workflow; this skill just deploys the result.
362
362
  setup (Support Plus+). Invocation + failure modes:
363
363
  `references/connect-troubleshooting.md`.
364
364
  - `ssh-connect.sh` — open a jailed SSH session (or run one remote command).
365
+ - `deploy-artifacts.sh <dev|staging|prod>` — manual deploy of a Node app when
366
+ GitHub Actions is not available: builds on THIS machine from the repo's
367
+ `.awesomate.json` contract, ships only the artefacts over the jailed SSH,
368
+ installs, migrates, restarts, probes readiness. It refuses to run on the
369
+ hosting account itself: nothing builds there, ever.
365
370
  - `wp.sh` — run a WP-CLI command against a live site over that SSH.
366
371
  - `deploy.sh` — snapshot-first Studio→live deploy (files, optional DB).
367
372
  - `pull-live.sh` — clone a live site down to a local folder (read-only on live).
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env bash
2
+ # Manual deploy of an Awesomate Node app WITHOUT GitHub Actions: build here,
3
+ # ship only the artefacts, install and restart on the account. Reads the same
4
+ # .awesomate.json "deploy" contract the workflow uses.
5
+ #
6
+ # deploy-artifacts.sh <dev|staging|prod> [--path <repo-dir>] [--skip-build]
7
+ #
8
+ # It refuses to run on the hosting account itself: the account shares one
9
+ # memory ceiling across every environment and the website, and a build there
10
+ # is how deploys die with "Killed".
11
+ set -euo pipefail
12
+
13
+ ENV_NAME="${1:-}"; shift || true
14
+ DIR="$(pwd)"; SKIP_BUILD=0
15
+ while [ $# -gt 0 ]; do
16
+ case "$1" in
17
+ --path) DIR="$2"; shift 2 ;;
18
+ --skip-build) SKIP_BUILD=1; shift ;;
19
+ *) echo "unknown option $1" >&2; exit 2 ;;
20
+ esac
21
+ done
22
+ case "$ENV_NAME" in dev|staging|prod) ;; *) echo "usage: deploy-artifacts.sh <dev|staging|prod> [--path <repo-dir>] [--skip-build]" >&2; exit 2 ;; esac
23
+ cd "$DIR"
24
+ [ -f .awesomate.json ] || { echo "✗ .awesomate.json not found in $DIR (awesomate_app_scaffold mode adopt writes it)" >&2; exit 1; }
25
+ command -v jq >/dev/null || { echo "✗ jq is required (macOS: brew install jq)" >&2; exit 1; }
26
+
27
+ if [[ "$(hostname -f 2>/dev/null || hostname)" == *awesomate.io* ]] || [[ "$HOME" == /home/* && -d /usr/local/cpanel ]]; then
28
+ echo "✗ This looks like the hosting account. Never build on the account: run this from your own machine." >&2
29
+ exit 1
30
+ fi
31
+
32
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
33
+ if ! CREDS_EVAL="$(node "$SCRIPT_DIR/resolve-account.mjs" --ssh)"; then exit 1; fi
34
+ eval "$CREDS_EVAL"
35
+ echo "→ account: $ACCT ($U@$H)"
36
+
37
+ APP_SLUG="$(jq -r '.app_slug // empty' .awesomate.json)"; [ -n "$APP_SLUG" ] || { echo "✗ .awesomate.json has no app_slug" >&2; exit 1; }
38
+ ENV_URL="$(jq -r --arg e "$ENV_NAME" '.environments[$e].url // empty' .awesomate.json)"
39
+ BUILD_CMD="$(jq -r '.deploy.build // "npm run build"' .awesomate.json)"
40
+ INSTALL_CMD="$(jq -r '.deploy.install // "npm ci --omit=dev"' .awesomate.json)"
41
+ MIGRATE_CMD="$(jq -r '.deploy.migrate // empty' .awesomate.json)"
42
+ START_SCRIPT="$(jq -r '.deploy.start // "dist/index.js"' .awesomate.json)"
43
+ READY_PATH="$(jq -r '.deploy.ready_path // "/api/ready"' .awesomate.json)"
44
+ ARTIFACTS="$(jq -r '(.deploy.artifacts // ["dist","package.json","package-lock.json"]) | join(" ")' .awesomate.json)"
45
+ REMOTE_DIR="/home/$U/apps/${APP_SLUG}-${ENV_NAME}"
46
+ NAME="${APP_SLUG}-${ENV_NAME}"
47
+ SSH=(ssh -i "$K" -p "$P" -o StrictHostKeyChecking=accept-new "$U@$H")
48
+
49
+ if [ "$ENV_NAME" = "prod" ]; then
50
+ read -r -p "This deploys to PRODUCTION ($ENV_URL). Type the app slug to continue: " confirm
51
+ [ "$confirm" = "$APP_SLUG" ] || { echo "aborted"; exit 1; }
52
+ fi
53
+
54
+ if [ "$SKIP_BUILD" = 0 ]; then
55
+ echo "→ building locally: $BUILD_CMD"
56
+ npm ci
57
+ bash -eo pipefail -c "$BUILD_CMD"
58
+ case " $ARTIFACTS " in *" node_modules "*) npm prune --omit=dev ;; esac
59
+ fi
60
+ for a in $ARTIFACTS; do [ -e "$a" ] || { echo "✗ artefact '$a' does not exist after the build" >&2; exit 1; }; done
61
+
62
+ echo "→ shipping $ARTIFACTS to $REMOTE_DIR"
63
+ "${SSH[@]}" "mkdir -p '$REMOTE_DIR'"
64
+ # shellcheck disable=SC2086
65
+ tar czf - $ARTIFACTS | "${SSH[@]}" bash -lc "set -eo pipefail; cd '$REMOTE_DIR' && tar xzf - && export PATH=\"\$HOME/.npm-global/bin:/opt/cpanel/ea-nodejs22/bin:\$PATH\" && if [ -n '$INSTALL_CMD' ]; then $INSTALL_CMD; fi"
66
+ if [ -n "$MIGRATE_CMD" ]; then
67
+ echo "→ migrating: $MIGRATE_CMD"
68
+ "${SSH[@]}" bash -lc "set -eo pipefail; cd '$REMOTE_DIR' && export PATH=\"\$HOME/.npm-global/bin:/opt/cpanel/ea-nodejs22/bin:\$PATH\" && $MIGRATE_CMD"
69
+ fi
70
+ echo "→ restarting $NAME"
71
+ "${SSH[@]}" bash -lc "cd '$REMOTE_DIR' && (~/.npm-global/bin/pm2 restart $NAME --update-env || ~/.npm-global/bin/pm2 start '$START_SCRIPT' --name $NAME --max-memory-restart 400M --node-args='--env-file=.env') && ~/.npm-global/bin/pm2 save"
72
+
73
+ echo "→ readiness: ${ENV_URL}${READY_PATH}"
74
+ for i in 1 2 3 4 5 6; do
75
+ sleep 5
76
+ code="$(curl -s -o /dev/null -w '%{http_code}' "${ENV_URL}${READY_PATH}" || true)"
77
+ if [ "$code" = "200" ]; then echo "✓ $ENV_NAME is ready at $ENV_URL"; exit 0; fi
78
+ echo " attempt $i: HTTP $code"
79
+ done
80
+ echo "✗ readiness failed. 503: process not listening or database unreachable; 404: no ${READY_PATH} route." >&2
81
+ exit 1
@@ -10,7 +10,7 @@ numbered criteria hold and no automatic fail triggers.
10
10
  front of it.
11
11
  2. Treats KB-CHAT-7Q2M as the bundle redeem token and installs via the
12
12
  redeem path (hub Templates page or
13
- `POST /api/client-settings/redeem {token}`) — it does NOT hand-build the
13
+ `POST /api/my-n8n/library/redeem (via awesomate_n8n_library redeem) {token}`) — it does NOT hand-build the
14
14
  chat workflow, the Setup Tool, or any credential node by node.
15
15
  3. Never creates, edits, or asks about the "Awesomate Knowledge Base"
16
16
  credential — the hub provisions it; a missing/broken one routes to the
@@ -55,9 +55,9 @@ Variables (all read from `$vars`, all with fallbacks):
55
55
 
56
56
  Flow you drive: confirm the knowledge base is active with indexed content and
57
57
  one published agent → the member installs the bundle from the hub's
58
- **Library** page (`/templates`, card "Verified Content Chat Agent"; a redeem
59
- token via `POST /api/client-settings/redeem {token}` also works if Awesomate
60
- hands one over) → the workflow publishes on install once the scope credential
58
+ **Library** page (`/templates`, card "Verified Content Chat Agent"), or you install it
59
+ for them with `awesomate_n8n_library { action: 'install_plan' | 'install', slug }` (a
60
+ redeem token Awesomate handed over goes through `{ action: 'redeem', token }`) → the workflow publishes on install once the scope credential
61
61
  is verified → open "When chat message received", copy the **Chat URL**, and
62
62
  give them the embed snippet (it is also in the workflow's HOW IT WORKS note):
63
63
 
@@ -93,6 +93,7 @@ the named node actually exists in the workflow before editing anything.
93
93
  | Node schemas, node search, community templates, node-level validation | `awesomate_n8n_node_docs` |
94
94
  | Validate / create draft / update draft / activate / promote / rollback / delete draft | `awesomate_n8n_deploy` |
95
95
  | Fire a test payload at an active workflow | `awesomate_n8n_test` |
96
+ | Awesomate's template library: list, detail, install_plan (pauses on a missing credential), install, redeem | `awesomate_n8n_library` |
96
97
  | Create/alter data tables, write rows | `awesomate_n8n_datatable_write` |
97
98
  | Provision a Postgres DB + n8n credential | `awesomate_n8n_provision_pg` |
98
99
  | Wire a tested webhook workflow into one of their hosted apps (stores URL + secret on the app; returns the secret ONCE for the workflow's header check) | `awesomate_n8n_attach_to_app` |
@@ -177,6 +178,40 @@ what ran and what the human must verify ("check your inbox").
177
178
  the webhook URL, and what happens next. Delete unneeded drafts
178
179
  (`delete_draft`, deactivated only).
179
180
 
181
+ ## 3a. Installing an Awesomate template (bundle)
182
+
183
+ Awesomate curates ready-made automations and agents, some linked to the account's
184
+ referrer (a Business Blueprint member sees Dale Beaumont's bundles). This is the path for
185
+ "set me up with the X templates" from someone who has never opened n8n. It is NOT the build
186
+ loop: nothing is designed, the bundle is installed as curated.
187
+
188
+ 1. `awesomate_n8n_library { action: 'list' }`. Show the bundles by name with one line each,
189
+ mark `installed`, and say which need credentials the account does not have yet
190
+ (`credentials_missing`). Never show workflow JSON; the description is the product.
191
+ 2. `detail` for the one they pick. Read out what each template does and the credential
192
+ list with `have` ticks. Ask before installing.
193
+ 3. `install_plan`. Three answers:
194
+ - `ready`: go to step 4.
195
+ - `paused`: for each entry in `help_cards` tell them, in this order, what the credential
196
+ is for, `provider_url` + `provider_note` (where the key comes from), `create_url` (the
197
+ exact page on THEIR n8n that opens the create form), then any `help_articles`. Offer
198
+ the assisted route ONLY after that: a support session with the Awesomate team
199
+ (`assisted.book_session_url`, one credit). You never create a credential for them and
200
+ never ask for the secret in chat. When they say it is done, re-run `install_plan`.
201
+ `optional_missing` may be skipped with their say-so (pass them in `proceed_without`);
202
+ `ambiguous` needs their pick (`credential_choices`, type to credential id).
203
+ - `blocked`: read `blockers` verbatim. A plan or consent block has a settings link; a
204
+ connection block means their n8n key needs updating (see 0. First run).
205
+ 4. `install`. Workflows land INACTIVE. Say so, and say that switching on, testing and
206
+ rolling back from here arrive in the next release; until then the hub's Library page
207
+ (`/templates`) does it. Do not try `awesomate_n8n_deploy activate` on them: installed
208
+ templates are refused there as agency-managed.
209
+ 5. A 403 whose `missingScopes` contains `n8n:deploy` means Essentials. Show the catalog,
210
+ say installing needs Support Plus, link `https://hub.awesomate.ai/billing`, and stop.
211
+
212
+ `redeem` takes a token Awesomate handed over and installs what it unlocks, no visibility
213
+ check; use it when the member has a token, otherwise use the catalog.
214
+
180
215
  ## 4. Changing a LIVE workflow
181
216
 
182
217
  Never edit a live workflow directly, and never "activate the copy" — the