@indigoai-us/hq-cli 5.14.0 → 5.15.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/.github/workflows/ci.yml +21 -0
- package/.github/workflows/publish.yml +86 -0
- package/.github/workflows/scripts/smoke-test-pkg.sh +97 -0
- package/CHANGELOG.md +19 -0
- package/dist/commands/cloud-provision.d.ts +40 -3
- package/dist/commands/cloud-provision.js +131 -8
- package/dist/commands/meetings.d.ts +3 -0
- package/dist/commands/meetings.js +374 -0
- package/dist/commands/pack-install.d.ts +31 -3
- package/dist/commands/pack-install.js +43 -31
- package/dist/index.js +5 -2
- package/dist/utils/cognito-session.d.ts +25 -0
- package/dist/utils/cognito-session.js +50 -3
- package/package.json +3 -2
- package/src/commands/cloud-provision.test.ts +291 -0
- package/src/commands/cloud-provision.ts +143 -9
- package/src/commands/meetings.ts +487 -0
- package/src/commands/pack-install.test.ts +148 -0
- package/src/commands/pack-install.ts +42 -44
- package/src/index.ts +4 -0
- package/src/utils/cognito-session.test.ts +112 -0
- package/src/utils/cognito-session.ts +46 -1
- package/tsconfig.json +12 -1
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
on:
|
|
3
|
+
pull_request:
|
|
4
|
+
branches: [main]
|
|
5
|
+
push:
|
|
6
|
+
branches: [main]
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: actions/setup-node@v4
|
|
14
|
+
with:
|
|
15
|
+
node-version: 22
|
|
16
|
+
- run: npm ci
|
|
17
|
+
# generate-dsn.mjs only fatals when GITHUB_JOB=publish, so CI builds with
|
|
18
|
+
# an empty BUNDLED_DSN. That's intentional — the DSN is publish-only.
|
|
19
|
+
- run: npm run build --if-present
|
|
20
|
+
- run: npm run typecheck --if-present
|
|
21
|
+
- run: npm test --if-present
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
name: Publish to npm
|
|
2
|
+
on:
|
|
3
|
+
push:
|
|
4
|
+
tags: ["v*"]
|
|
5
|
+
|
|
6
|
+
jobs:
|
|
7
|
+
publish:
|
|
8
|
+
runs-on: ubuntu-latest
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
id-token: write
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: actions/setup-node@v4
|
|
15
|
+
with:
|
|
16
|
+
# Node 24 ships npm 11.x, required for npm's trusted-publisher OIDC
|
|
17
|
+
# token-exchange flow. See hq-cloud's publish.yml for the full
|
|
18
|
+
# rationale (npm 10.x produces masked-404 failures on publish PUT).
|
|
19
|
+
node-version: 24
|
|
20
|
+
registry-url: https://registry.npmjs.org
|
|
21
|
+
|
|
22
|
+
- run: node --version && npm --version
|
|
23
|
+
- run: npm ci
|
|
24
|
+
|
|
25
|
+
- name: Build
|
|
26
|
+
run: npm run build --if-present
|
|
27
|
+
env:
|
|
28
|
+
# generate-dsn.mjs fatals when GITHUB_JOB=publish and this var is
|
|
29
|
+
# missing. Set this as a repo secret to bundle a Sentry DSN into the
|
|
30
|
+
# published binary.
|
|
31
|
+
HQ_CLI_PUBLISH_SENTRY_DSN: ${{ secrets.HQ_CLI_PUBLISH_SENTRY_DSN }}
|
|
32
|
+
|
|
33
|
+
# Inject Sentry debug IDs into compiled JS + map files. Must run after
|
|
34
|
+
# tsc and BEFORE publish so the installed binary carries the same debugId
|
|
35
|
+
# as the artifacts uploaded to Sentry, enabling debugId-based source-map
|
|
36
|
+
# resolution for globally-installed CLI users.
|
|
37
|
+
- name: Inject Sentry source map debug IDs
|
|
38
|
+
run: npx -y @sentry/cli@^2 sourcemaps inject dist/
|
|
39
|
+
|
|
40
|
+
# Pre-publish cross-package smoke test (Bug A guard).
|
|
41
|
+
#
|
|
42
|
+
# @indigoai-us/hq-cli depends on @indigoai-us/hq-cloud via a caret range
|
|
43
|
+
# (^5.1.0). The smoke installs a freshly-packed tarball into an isolated
|
|
44
|
+
# tmpdir — outside this repo — so transitive @indigoai-us/* deps resolve
|
|
45
|
+
# from the npm registry, NOT from a local checkout. Booting `hq --version`
|
|
46
|
+
# then exercises the full module-load graph. If hq-cloud removed or
|
|
47
|
+
# renamed a symbol hq-cli imports, this catches it BEFORE the publish PUT.
|
|
48
|
+
#
|
|
49
|
+
# Bypass: set repo-or-workflow variable WORKFLOW_ALLOW_BROKEN_PUBLISH=1.
|
|
50
|
+
- name: Pre-publish smoke test
|
|
51
|
+
if: ${{ vars.WORKFLOW_ALLOW_BROKEN_PUBLISH != '1' }}
|
|
52
|
+
run: bash .github/workflows/scripts/smoke-test-pkg.sh hq
|
|
53
|
+
|
|
54
|
+
- name: WARN — pre-publish smoke bypassed
|
|
55
|
+
if: ${{ vars.WORKFLOW_ALLOW_BROKEN_PUBLISH == '1' }}
|
|
56
|
+
run: |
|
|
57
|
+
echo "::warning::WORKFLOW_ALLOW_BROKEN_PUBLISH=1 — pre-publish cross-package smoke test was BYPASSED. This release may ship a DOA package."
|
|
58
|
+
|
|
59
|
+
# Trusted-publisher OIDC; no NODE_AUTH_TOKEN. No --provenance (private
|
|
60
|
+
# repo limitation since npm 2026-05-10). Pre-release versions (with '-')
|
|
61
|
+
# get --tag rc to keep latest dist-tag pointing at stable releases only.
|
|
62
|
+
- name: Publish to npm
|
|
63
|
+
id: publish
|
|
64
|
+
run: |
|
|
65
|
+
NAME=$(jq -r .name package.json)
|
|
66
|
+
VER=$(jq -r .version package.json)
|
|
67
|
+
if npm view "$NAME@$VER" version >/dev/null 2>&1; then
|
|
68
|
+
echo "$NAME@$VER already on npm — skipping"
|
|
69
|
+
else
|
|
70
|
+
if echo "$VER" | grep -q '-'; then
|
|
71
|
+
npm publish --access public --tag rc
|
|
72
|
+
else
|
|
73
|
+
npm publish --access public
|
|
74
|
+
fi
|
|
75
|
+
echo "published=true" >> "$GITHUB_OUTPUT"
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
- name: Upload sourcemaps to Sentry
|
|
79
|
+
if: steps.publish.outputs.published == 'true'
|
|
80
|
+
run: |
|
|
81
|
+
VER=$(jq -r .version package.json)
|
|
82
|
+
npx -y @sentry/cli@^2 sourcemaps upload --release "hq-cli@$VER" dist/
|
|
83
|
+
env:
|
|
84
|
+
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
85
|
+
SENTRY_ORG: indigo-d0
|
|
86
|
+
SENTRY_PROJECT: hq
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# smoke-test-pkg.sh — pre-publish cross-package import smoke test (Bug A guard).
|
|
3
|
+
#
|
|
4
|
+
# Single-package adaptation of the same script used in the legacy hq monorepo.
|
|
5
|
+
# The monorepo version operated on `packages/$PKG/package.json`; this version
|
|
6
|
+
# reads `./package.json` because each extracted repo is a single package at
|
|
7
|
+
# its root.
|
|
8
|
+
#
|
|
9
|
+
# Install the package in an isolated tmpdir — using a freshly-packed tarball
|
|
10
|
+
# when the package.json version is new, or fetching the existing name@version
|
|
11
|
+
# from npm when it is already published — then boot the binary with
|
|
12
|
+
# `--version`. The boot exercises the full module-load graph and the
|
|
13
|
+
# transitive `@indigoai-us/*` resolution path a customer's `npm install`
|
|
14
|
+
# would take. If the boot fails the script exits non-zero and the workflow
|
|
15
|
+
# aborts before any further publish steps run.
|
|
16
|
+
#
|
|
17
|
+
# Why we don't skip when the consumer's version is already on npm:
|
|
18
|
+
# @indigoai-us/hq-cli depends on @indigoai-us/hq-cloud via a caret range
|
|
19
|
+
# (^5.1.0). A producer bump (published from indigoai-us/hq-cloud) re-resolves
|
|
20
|
+
# this consumer's transitive deps on the next fresh install, so an already-
|
|
21
|
+
# published consumer is NOT actually "locked." If the new producer drops or
|
|
22
|
+
# renames a symbol the published consumer imports, `hq --version` crashes
|
|
23
|
+
# on every fresh install starting the moment the producer publishes.
|
|
24
|
+
#
|
|
25
|
+
# Usage:
|
|
26
|
+
# smoke-test-pkg.sh <bin-name>
|
|
27
|
+
# Example:
|
|
28
|
+
# smoke-test-pkg.sh hq
|
|
29
|
+
|
|
30
|
+
set -uo pipefail
|
|
31
|
+
|
|
32
|
+
BIN=${1:?usage: smoke-test-pkg.sh <bin-name>}
|
|
33
|
+
|
|
34
|
+
PKG_JSON="package.json"
|
|
35
|
+
if [ ! -f "$PKG_JSON" ]; then
|
|
36
|
+
echo "::error::smoke-test-pkg.sh: $PKG_JSON not found (cwd: $(pwd))"
|
|
37
|
+
exit 2
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
NAME=$(jq -r .name "$PKG_JSON")
|
|
41
|
+
VER=$(jq -r .version "$PKG_JSON")
|
|
42
|
+
|
|
43
|
+
# Pick the install target. Already on npm → install by name@version so the
|
|
44
|
+
# smoke matches what a customer would actually receive. Not yet on npm →
|
|
45
|
+
# pack the local repo; the next publish step will push that exact byte-for-
|
|
46
|
+
# byte content. Either way, `npm install` happens OUTSIDE this repo so
|
|
47
|
+
# transitive @indigoai-us/* deps resolve from the registry.
|
|
48
|
+
INSTALL_SOURCE=""
|
|
49
|
+
if npm view "$NAME@$VER" version >/dev/null 2>&1; then
|
|
50
|
+
INSTALL_TARGET="$NAME@$VER"
|
|
51
|
+
INSTALL_SOURCE="npm registry (consumer version already published)"
|
|
52
|
+
else
|
|
53
|
+
TARBALL_RELATIVE=$(npm pack --silent --pack-destination /tmp)
|
|
54
|
+
if [ -z "$TARBALL_RELATIVE" ]; then
|
|
55
|
+
echo "::error::npm pack produced no tarball for $NAME"
|
|
56
|
+
exit 1
|
|
57
|
+
fi
|
|
58
|
+
INSTALL_TARGET="/tmp/$TARBALL_RELATIVE"
|
|
59
|
+
if [ ! -f "$INSTALL_TARGET" ]; then
|
|
60
|
+
echo "::error::expected tarball at $INSTALL_TARGET not found"
|
|
61
|
+
exit 1
|
|
62
|
+
fi
|
|
63
|
+
INSTALL_SOURCE="packed tarball ($TARBALL_RELATIVE)"
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
echo "::group::smoke test: $NAME@$VER via $INSTALL_SOURCE"
|
|
67
|
+
|
|
68
|
+
SMOKE=$(mktemp -d)
|
|
69
|
+
pushd "$SMOKE" >/dev/null
|
|
70
|
+
|
|
71
|
+
printf '%s' '{"name":"smoke","version":"0.0.0","private":true}' > package.json
|
|
72
|
+
|
|
73
|
+
if ! npm install --no-audit --no-fund --no-package-lock "$INSTALL_TARGET" >/tmp/smoke-install.log 2>&1; then
|
|
74
|
+
echo "::error::pre-publish smoke install FAILED for $NAME@$VER:"
|
|
75
|
+
tail -50 /tmp/smoke-install.log
|
|
76
|
+
popd >/dev/null
|
|
77
|
+
echo "::endgroup::"
|
|
78
|
+
exit 1
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
set +e
|
|
82
|
+
OUT=$("./node_modules/.bin/$BIN" --version 2>&1)
|
|
83
|
+
CODE=$?
|
|
84
|
+
set -e
|
|
85
|
+
|
|
86
|
+
popd >/dev/null
|
|
87
|
+
|
|
88
|
+
if [ "$CODE" -ne 0 ]; then
|
|
89
|
+
echo "::error::pre-publish smoke FAILED for $NAME@$VER (exit $CODE):"
|
|
90
|
+
echo "::error::$OUT"
|
|
91
|
+
echo "::error::Likely cause: a cross-package symbol was changed (added, removed, renamed) in another @indigoai-us/* package without keeping this consumer's import in sync. Bump this consumer (or revert the producer change) and re-tag, OR set the repo/workflow variable WORKFLOW_ALLOW_BROKEN_PUBLISH=1 to override (audited)."
|
|
92
|
+
echo "::endgroup::"
|
|
93
|
+
exit 1
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
echo "✓ smoke ok: $NAME@$VER → $BIN --version → $OUT (via $INSTALL_SOURCE)"
|
|
97
|
+
echo "::endgroup::"
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.14.1] — 2026-05-14
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **`hq install <pack>` now writes to `core/packages/<name>/`** under the
|
|
10
|
+
v12 HQ layout, not top-level `packages/<name>/`. Pre-v12 behavior left
|
|
11
|
+
an orphan tree alongside the canonical `core/packages/` shipped by the
|
|
12
|
+
template (`hq-core` / `hq-core-staging`).
|
|
13
|
+
- **Post-install `scan-packages.sh` is now resolved at
|
|
14
|
+
`core/scripts/scan-packages.sh`** instead of top-level `scripts/`.
|
|
15
|
+
- **Stopped writing pack entries to `modules/modules.yaml`.** Packs are
|
|
16
|
+
tracked by filesystem presence under v12 (no separate registry file).
|
|
17
|
+
`hq update <pack>` will re-resolve source from each pack's
|
|
18
|
+
`package.yaml` or prompt; this is intentional. The legacy
|
|
19
|
+
knowledge-modules feature (`strategy: 'git-clone'`) is untouched.
|
|
20
|
+
|
|
21
|
+
(Surfaced during end-to-end testing of the hq-installer staging-channel
|
|
22
|
+
toggle — see indigoai-us/hq-installer#61.)
|
|
23
|
+
|
|
5
24
|
## [5.14.0] — 2026-05-14
|
|
6
25
|
|
|
7
26
|
### Changed
|
|
@@ -20,7 +20,10 @@
|
|
|
20
20
|
* Exit codes:
|
|
21
21
|
* 0 — success (and `initial_sync.ok=true`)
|
|
22
22
|
* 1 — vault auth/network/API error (no entity provisioned)
|
|
23
|
-
* 2 — invalid slug, company missing
|
|
23
|
+
* 2 — invalid slug, company dir missing, or company is status=archived.
|
|
24
|
+
* Folder-only states (companies/<slug>/ exists on disk but no manifest
|
|
25
|
+
* entry) self-heal via ensureManifestEntryForProvision before
|
|
26
|
+
* validation runs, so a missing manifest entry alone is NOT an exit-2.
|
|
24
27
|
* 3 — sync failure after entity provisioned (cloud_uid in JSON;
|
|
25
28
|
* `initial_sync.ok=false`). Manifest + config may have been written.
|
|
26
29
|
*/
|
|
@@ -99,6 +102,15 @@ interface InitialSyncArgs {
|
|
|
99
102
|
}
|
|
100
103
|
/** Vault HTTP client interface — minimal surface for entity ops. */
|
|
101
104
|
export interface VaultClient {
|
|
105
|
+
/**
|
|
106
|
+
* List every person entity visible to the caller (scoped server-side by the
|
|
107
|
+
* caller's Cognito identity). Used by `provisionCompany` as a pre-flight
|
|
108
|
+
* to verify the caller has been onboarded before mutating any cloud-side
|
|
109
|
+
* resources — the initial-sync step at the end of provision needs to vend
|
|
110
|
+
* STS credentials, which the server only does for callers with a person
|
|
111
|
+
* entity.
|
|
112
|
+
*/
|
|
113
|
+
listMyPersonEntities(): Promise<VaultEntity[]>;
|
|
102
114
|
findCompanyBySlug(slug: string): Promise<VaultEntity | null>;
|
|
103
115
|
createCompanyEntity(input: {
|
|
104
116
|
slug: string;
|
|
@@ -114,8 +126,10 @@ export declare class ProvisionError extends Error {
|
|
|
114
126
|
}
|
|
115
127
|
/**
|
|
116
128
|
* Validate a company slug per the contract: alphanumeric / dot / dash / underscore,
|
|
117
|
-
* non-empty,
|
|
118
|
-
*
|
|
129
|
+
* non-empty, never `"."` or `".."` (path-traversal — would resolve to
|
|
130
|
+
* `companies/` itself or `<hqRoot>` and write outside the intended company
|
|
131
|
+
* directory), and never `"personal"` (auto-provisioned per-user, not promoted
|
|
132
|
+
* via this subcommand).
|
|
119
133
|
*
|
|
120
134
|
* Throws ProvisionError with code=2 on failure.
|
|
121
135
|
*/
|
|
@@ -136,11 +150,34 @@ export declare function companyConfigPath(hqRoot: string, slug: string): string;
|
|
|
136
150
|
* - company is `status: archived`
|
|
137
151
|
* - `companies/<slug>/` does not exist
|
|
138
152
|
*
|
|
153
|
+
* Strict on purpose — callers that want the "auto-insert a missing entry when
|
|
154
|
+
* the local folder exists" affordance (cloud-provision's fault-tolerant flow)
|
|
155
|
+
* MUST call `ensureManifestEntryForProvision` first. cloud-demote and other
|
|
156
|
+
* sensitive callers depend on this strict failure for typo-catching.
|
|
157
|
+
*
|
|
139
158
|
* Returns the parsed manifest (so the caller can re-use it for the patch step).
|
|
140
159
|
*/
|
|
141
160
|
export declare function validateManifestAndDir(hqRoot: string, slug: string): {
|
|
142
161
|
manifest: ManifestDoc;
|
|
143
162
|
};
|
|
163
|
+
/**
|
|
164
|
+
* Provision-only pre-step. If `companies/<slug>/` exists on disk but the slug
|
|
165
|
+
* is absent from `manifest.yaml`, atomically insert an empty entry
|
|
166
|
+
* (`<slug>: {}`) so the downstream `validateManifestAndDir` + `patchManifest`
|
|
167
|
+
* flow has something to operate on. Heals the common folder-only state
|
|
168
|
+
* (manual mkdir, or older tools that didn't patch manifest.yaml) that hq-sync's
|
|
169
|
+
* Connect button used to dead-end on with exit 2.
|
|
170
|
+
*
|
|
171
|
+
* No-op in every other case:
|
|
172
|
+
* - manifest file missing / malformed (validation will surface)
|
|
173
|
+
* - slug already present (any value, including null — preserved as-is)
|
|
174
|
+
* - `companies/<slug>/` does not exist (validation will surface as a typo)
|
|
175
|
+
*
|
|
176
|
+
* This affordance is provision-specific. `cloud-demote` and other strict
|
|
177
|
+
* callers MUST NOT call this — their typo-catching contract depends on
|
|
178
|
+
* `validateManifestAndDir` throwing on missing-slug.
|
|
179
|
+
*/
|
|
180
|
+
export declare function ensureManifestEntryForProvision(hqRoot: string, slug: string): void;
|
|
144
181
|
/**
|
|
145
182
|
* Top-level manifest shape we touch. We preserve all unknown fields — only
|
|
146
183
|
* `cloud_uid` and `bucket_name` under the target slug are mutated.
|
|
@@ -20,12 +20,15 @@
|
|
|
20
20
|
* Exit codes:
|
|
21
21
|
* 0 — success (and `initial_sync.ok=true`)
|
|
22
22
|
* 1 — vault auth/network/API error (no entity provisioned)
|
|
23
|
-
* 2 — invalid slug, company missing
|
|
23
|
+
* 2 — invalid slug, company dir missing, or company is status=archived.
|
|
24
|
+
* Folder-only states (companies/<slug>/ exists on disk but no manifest
|
|
25
|
+
* entry) self-heal via ensureManifestEntryForProvision before
|
|
26
|
+
* validation runs, so a missing manifest entry alone is NOT an exit-2.
|
|
24
27
|
* 3 — sync failure after entity provisioned (cloud_uid in JSON;
|
|
25
28
|
* `initial_sync.ok=false`). Manifest + config may have been written.
|
|
26
29
|
*/
|
|
27
30
|
|
|
28
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
31
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="3c9e69f3-8137-5db1-8692-e38774558f61")}catch(e){}}();
|
|
29
32
|
import chalk from "chalk";
|
|
30
33
|
import * as fs from "node:fs";
|
|
31
34
|
import * as path from "node:path";
|
|
@@ -45,11 +48,13 @@ export class ProvisionError extends Error {
|
|
|
45
48
|
}
|
|
46
49
|
// ── Validation ───────────────────────────────────────────────────────────────
|
|
47
50
|
const SLUG_REGEX = /^[A-Za-z0-9._-]+$/;
|
|
48
|
-
const FORBIDDEN_SLUGS = new Set(["personal"]);
|
|
51
|
+
const FORBIDDEN_SLUGS = new Set(["personal", ".", ".."]);
|
|
49
52
|
/**
|
|
50
53
|
* Validate a company slug per the contract: alphanumeric / dot / dash / underscore,
|
|
51
|
-
* non-empty,
|
|
52
|
-
*
|
|
54
|
+
* non-empty, never `"."` or `".."` (path-traversal — would resolve to
|
|
55
|
+
* `companies/` itself or `<hqRoot>` and write outside the intended company
|
|
56
|
+
* directory), and never `"personal"` (auto-provisioned per-user, not promoted
|
|
57
|
+
* via this subcommand).
|
|
53
58
|
*
|
|
54
59
|
* Throws ProvisionError with code=2 on failure.
|
|
55
60
|
*/
|
|
@@ -61,7 +66,10 @@ export function validateSlug(slug) {
|
|
|
61
66
|
throw new ProvisionError(2, `Invalid slug "${slug}" — must match ${SLUG_REGEX.source}`);
|
|
62
67
|
}
|
|
63
68
|
if (FORBIDDEN_SLUGS.has(slug)) {
|
|
64
|
-
|
|
69
|
+
const reason = slug === "personal"
|
|
70
|
+
? "auto-provisioned per-user, not eligible for cloud promotion"
|
|
71
|
+
: "path-traversal slug (would resolve to a directory outside the intended company folder)";
|
|
72
|
+
throw new ProvisionError(2, `Slug "${slug}" is reserved (${reason})`);
|
|
65
73
|
}
|
|
66
74
|
}
|
|
67
75
|
/** Path to the top-level companies manifest file. */
|
|
@@ -86,6 +94,11 @@ export function companyConfigPath(hqRoot, slug) {
|
|
|
86
94
|
* - company is `status: archived`
|
|
87
95
|
* - `companies/<slug>/` does not exist
|
|
88
96
|
*
|
|
97
|
+
* Strict on purpose — callers that want the "auto-insert a missing entry when
|
|
98
|
+
* the local folder exists" affordance (cloud-provision's fault-tolerant flow)
|
|
99
|
+
* MUST call `ensureManifestEntryForProvision` first. cloud-demote and other
|
|
100
|
+
* sensitive callers depend on this strict failure for typo-catching.
|
|
101
|
+
*
|
|
89
102
|
* Returns the parsed manifest (so the caller can re-use it for the patch step).
|
|
90
103
|
*/
|
|
91
104
|
export function validateManifestAndDir(hqRoot, slug) {
|
|
@@ -115,6 +128,82 @@ export function validateManifestAndDir(hqRoot, slug) {
|
|
|
115
128
|
}
|
|
116
129
|
return { manifest };
|
|
117
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Provision-only pre-step. If `companies/<slug>/` exists on disk but the slug
|
|
133
|
+
* is absent from `manifest.yaml`, atomically insert an empty entry
|
|
134
|
+
* (`<slug>: {}`) so the downstream `validateManifestAndDir` + `patchManifest`
|
|
135
|
+
* flow has something to operate on. Heals the common folder-only state
|
|
136
|
+
* (manual mkdir, or older tools that didn't patch manifest.yaml) that hq-sync's
|
|
137
|
+
* Connect button used to dead-end on with exit 2.
|
|
138
|
+
*
|
|
139
|
+
* No-op in every other case:
|
|
140
|
+
* - manifest file missing / malformed (validation will surface)
|
|
141
|
+
* - slug already present (any value, including null — preserved as-is)
|
|
142
|
+
* - `companies/<slug>/` does not exist (validation will surface as a typo)
|
|
143
|
+
*
|
|
144
|
+
* This affordance is provision-specific. `cloud-demote` and other strict
|
|
145
|
+
* callers MUST NOT call this — their typo-catching contract depends on
|
|
146
|
+
* `validateManifestAndDir` throwing on missing-slug.
|
|
147
|
+
*/
|
|
148
|
+
export function ensureManifestEntryForProvision(hqRoot, slug) {
|
|
149
|
+
const mPath = manifestPath(hqRoot);
|
|
150
|
+
if (!fs.existsSync(mPath))
|
|
151
|
+
return;
|
|
152
|
+
// Defense-in-depth against path-traversal slugs (`.`, `..`, anything that
|
|
153
|
+
// resolves outside companies/). validateSlug rejects these upstream, but
|
|
154
|
+
// this helper is exported and might be called directly by future code —
|
|
155
|
+
// never auto-insert based on a path that isn't a literal direct child of
|
|
156
|
+
// `companies/`. fs.realpathSync would also catch symlink-escapes, but the
|
|
157
|
+
// directory may legitimately not exist yet on disk; path.resolve gives us
|
|
158
|
+
// the canonical lexical form without a stat call.
|
|
159
|
+
const companiesDir = path.resolve(hqRoot, "companies");
|
|
160
|
+
const expected = path.resolve(companiesDir, slug);
|
|
161
|
+
const expectedParent = path.dirname(expected);
|
|
162
|
+
if (expectedParent !== companiesDir)
|
|
163
|
+
return;
|
|
164
|
+
if (path.basename(expected) !== slug)
|
|
165
|
+
return;
|
|
166
|
+
const dir = companyDirPath(hqRoot, slug);
|
|
167
|
+
// Must be an actual directory, not a stray file. fs.existsSync returns true
|
|
168
|
+
// for regular files too — without this guard, auto-insert would fire on a
|
|
169
|
+
// file at `companies/<slug>`, provisionCompany would then create the vault
|
|
170
|
+
// entity and patch manifest.yaml before writeCompanyConfig's `mkdir -p .hq`
|
|
171
|
+
// exploded with ENOTDIR. statSync swallows the not-found case so a missing
|
|
172
|
+
// path is treated the same as before (no auto-insert).
|
|
173
|
+
let dirStat;
|
|
174
|
+
try {
|
|
175
|
+
dirStat = fs.statSync(dir);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (!dirStat.isDirectory())
|
|
181
|
+
return;
|
|
182
|
+
const raw = fs.readFileSync(mPath, "utf-8");
|
|
183
|
+
let parsed;
|
|
184
|
+
try {
|
|
185
|
+
parsed = yaml.load(raw);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (!parsed ||
|
|
191
|
+
typeof parsed !== "object" ||
|
|
192
|
+
!("companies" in parsed) ||
|
|
193
|
+
typeof parsed.companies !== "object") {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const doc = parsed;
|
|
197
|
+
if (!doc.companies)
|
|
198
|
+
doc.companies = {};
|
|
199
|
+
if (doc.companies[slug] !== undefined)
|
|
200
|
+
return;
|
|
201
|
+
doc.companies[slug] = {};
|
|
202
|
+
const dump = yaml.dump(doc, { lineWidth: -1, noRefs: true });
|
|
203
|
+
const tmp = `${mPath}.tmp.${process.pid}`;
|
|
204
|
+
fs.writeFileSync(tmp, dump);
|
|
205
|
+
fs.renameSync(tmp, mPath);
|
|
206
|
+
}
|
|
118
207
|
/**
|
|
119
208
|
* Atomically patch `companies/manifest.yaml` to set `cloud_uid` + `bucket_name`
|
|
120
209
|
* under the target slug. Read → mutate → temp-write → rename so concurrent
|
|
@@ -179,6 +268,18 @@ export function createDefaultVaultClient(apiUrl, accessToken) {
|
|
|
179
268
|
Authorization: `Bearer ${accessToken}`,
|
|
180
269
|
};
|
|
181
270
|
return {
|
|
271
|
+
async listMyPersonEntities() {
|
|
272
|
+
const url = `${apiUrl.replace(/\/$/, "")}/entity/by-type/person`;
|
|
273
|
+
const res = await fetch(url, { method: "GET", headers });
|
|
274
|
+
if (res.status === 404)
|
|
275
|
+
return [];
|
|
276
|
+
if (!res.ok) {
|
|
277
|
+
const body = await safeBody(res);
|
|
278
|
+
throw new ProvisionError(1, `Vault GET /entity/by-type/person failed: ${res.status} ${res.statusText} — ${body}`);
|
|
279
|
+
}
|
|
280
|
+
const data = (await res.json());
|
|
281
|
+
return data.entities ?? [];
|
|
282
|
+
},
|
|
182
283
|
async findCompanyBySlug(slug) {
|
|
183
284
|
const url = `${apiUrl.replace(/\/$/, "")}/entity/by-slug/company/${encodeURIComponent(slug)}`;
|
|
184
285
|
const res = await fetch(url, { method: "GET", headers });
|
|
@@ -258,8 +359,13 @@ async function defaultRunInitialSync(args) {
|
|
|
258
359
|
*/
|
|
259
360
|
export async function provisionCompany(options) {
|
|
260
361
|
const log = options.log ?? ((msg) => process.stderr.write(`[hq cloud provision] ${msg}\n`));
|
|
261
|
-
// Step 1+2+3: validate slug, manifest,
|
|
362
|
+
// Step 1+2+3: validate slug, auto-heal a folder-only manifest, then validate
|
|
363
|
+
// the resulting manifest + dir. The auto-heal is provision-specific: it
|
|
364
|
+
// inserts an empty entry when companies/<slug>/ exists on disk but the slug
|
|
365
|
+
// is missing from manifest.yaml. validateManifestAndDir stays strict so
|
|
366
|
+
// other callers (cloud-demote) keep their typo-catching guard.
|
|
262
367
|
validateSlug(options.slug);
|
|
368
|
+
ensureManifestEntryForProvision(options.hqRoot, options.slug);
|
|
263
369
|
validateManifestAndDir(options.hqRoot, options.slug);
|
|
264
370
|
log(`validated slug=${options.slug}`);
|
|
265
371
|
// Step 4: auth — defer to injected resolver (default: ensureCognitoToken)
|
|
@@ -270,6 +376,23 @@ export async function provisionCompany(options) {
|
|
|
270
376
|
// Step 5: GET-then-POST for idempotency
|
|
271
377
|
const vaultClient = options.vaultClient ??
|
|
272
378
|
createDefaultVaultClient(options.vaultApiUrl, accessToken);
|
|
379
|
+
// Pre-flight: caller MUST have a registered person entity. Without one,
|
|
380
|
+
// the initial-sync step at the end of this flow (STS /sts/vend) returns
|
|
381
|
+
// 403 "no person entity" and leaves the operator with a half-built cloud
|
|
382
|
+
// company — vault entity created, S3 bucket provisioned, manifest patched,
|
|
383
|
+
// .hq/config.json written, but nothing actually syncing. This was the
|
|
384
|
+
// primary failure mode in the 2026-05-14 setup-session deep dive
|
|
385
|
+
// (Joey Muller / sum-digital).
|
|
386
|
+
//
|
|
387
|
+
// Failing here, BEFORE any cloud-side resource creation, leaves the operator
|
|
388
|
+
// with no cleanup work — they fix the underlying onboarding gap (run
|
|
389
|
+
// `hq onboard` / sign in with the correct federated identity) and re-run
|
|
390
|
+
// `hq cloud provision company <slug>` cleanly.
|
|
391
|
+
const persons = await vaultClient.listMyPersonEntities();
|
|
392
|
+
if (persons.length === 0) {
|
|
393
|
+
throw new ProvisionError(2, 'No person entity found for this Cognito identity. Run `hq onboard` first to create your HQ identity, then re-run `hq cloud provision company`. (Provision was halted before any cloud-side resources were created.)');
|
|
394
|
+
}
|
|
395
|
+
log(`pre-flight ok — caller has ${persons.length} person entity(ies)`);
|
|
273
396
|
let entity = await vaultClient.findCompanyBySlug(options.slug);
|
|
274
397
|
let createdEntity = false;
|
|
275
398
|
if (entity) {
|
|
@@ -426,4 +549,4 @@ export function registerCloudProvisionCommands(program) {
|
|
|
426
549
|
});
|
|
427
550
|
}
|
|
428
551
|
//# sourceMappingURL=cloud-provision.js.map
|
|
429
|
-
//# debugId=
|
|
552
|
+
//# debugId=3c9e69f3-8137-5db1-8692-e38774558f61
|